9. AJAX i API

Wyzwania:

  • dodasz możliwość zmiany liczby sztuk produktu,
  • dodasz obsługę koszyka na stronie pizzerii,
  • poznasz zagadnienia AJAX i API,
  • umożliwisz zapisanie zamówienia w API.

Wstęp

W tym module nadal będziemy zajmować się naszą stroną pizzerii. Zaczniemy od dodania wspomnianej już wcześniej funkcjonalności zmiany liczby sztuk w produkcie. Kiedy to zrobimy, przejdziemy do funkcjonalności koszyka. Zapowiada się pracowicie.

9.1. Nowa klasa – widget ilości

Zajmiemy się teraz widgetem wyboru ilości produktu, jednak tym razem nie będzie to kolejna metoda klasy Product, a nowa klasa! Dlaczego?

Cała idea OOP opiera się na tym, aby oddzielać od siebie grupy funkcjonalności. O ile metoda processOrder czy renderInMenu możemy jak najbardziej uznać za takie, które tyczą się konkretnie produktu, to widget ilości już nie. Owszem, będziemy z niego korzystać w klasie Product, ale czy tylko tam? Widget ilości to dość uniwersalna funkcjonalność. Na pewno przyda się również w innych miejscach, chociażby w koszyku. Przecież często widzimy w e-sklepach mechanizm, w którym po dodaniu produktu do koszyka, nadal można zmienić ilość sztuk. A kto wie, może pojawią się jeszcze kolejne miejsca, w których taki widget się przyda?

Gdybyśmy zamknęli go w klasie Product, to przy próbie użycia go jeszcze raz, gdzie indziej, zwyczajnie musielibyśmy powielić jego logikę. Jeśli wydzielimy go jednak do osobnej klasy, to bez problemu skorzystamy z jej instancji w produktach, ale i np. koszyku.

Specyfikacja klasy

Nowa klasa będzie trochę inna niż Product. Nasza pierwsza klasa otrzymywała w konstruktorze informacje o nazwie i strukturze produktu, ale resztę robiła już sama, np. renderowanie reprezentacji produktu w HTML było już jej rolą. Podobnie jak zdynamizowanie formularza, aby zmiany opcji faktycznie przeliczały cenę. W naszej nowej klasie (nazwiemy ją AmountWidget) będzie trochę inaczej.

Rolą AmountWidget ma być po prostu nadanie życia inputom liczbowym. Tak, aby można było łatwo i przyjemnie, za pomocą buttonów "+" i "-", zwiększać lub zmniejszać wartość pola. Oczywiście znajdzie się tam również walidacja, tak, aby nie można było wybrać liczby za małej albo za dużej lub wpisać tekstu. Czy jednak będzie zajmować się również tworzeniem samego elementu? Nie. Zwróć uwagę, że przecież input ilości sztuk już na stronie jest. Został wygenerowany przez instancje klasy Product. Nie trzeba go tworzyć od zera.

To duża zmiana, oznacza to bowiem, że AmountWidget nie będzie niczego sam generował. Zamiast tego otrzyma tylko referencję do odpowiedniego elementu jako argument konstruktora, a potem odpowiednio się nim zajmie.

Całość ma działać następująco:

image

Przy okazji zauważ jeszcze jedną funkcjonalność naszego widgetu. Ewidentnie widzimy, że cena produktu przelicza się każdorazowo przy zmianie ilości sztuk. Oznacza to, że na pewno nasz widget powinien być w stanie informować inne elementy np. instancje Product o zmianie takiej wartości. Tak, aby te miały możliwość zareagowania na to np. ponownym uruchomieniem metody processOrder.

No dobrze, trochę już wiemy, ale trzeba przejść do działania. Od czego zaczniemy?

Wiemy już, że instancje tej klasy nie będą musiały tworzyć własnych elementów DOM, ponieważ zostały one już stworzone przez instancje produktów. W takim razie konstruktor musi tylko otrzymywać odniesienie (referencję) do elementu, w którym widget ma zostać zainicjowany, żeby wiedzieć "na czym" pracować.

Co ważne tym elementem nie będzie sam input, lecz div, w którym takowy input się znajduje. Dlaczego? Bo wraz z inputem potrzebujemy mieć jeszcze dostęp do buttonów "+" i "-". W końcu będą ona dla nas istotne. Zamiast inputu będziemy więc przekazywać cały div:

image

Właśnie na tego typu element konstruktor klasy AmountWidget będzie oczekiwać. Dlatego też na pewno zaczniemy od przygotowania odpowiedniego konstruktora oraz funkcji, która stworzy właściwości z referencjami do trzech elementów otrzymanych w tym divie.

  1. inputu z wartością,
  2. linku zmniejszający wartość,
  3. linku zwiększający wartość.

Po co nam te referencje? Znasz już ten koncept. Żeby raz, w jednym miejscu, przygotować sobie "skrócony" dostęp do elementów, a potem w innych metodach łatwiej z nich korzystać. Podobnie postąpiliśmy w klasie Product, dodając metodę getElements. Przygotowaliśmy tam referencję, którą następnie wykorzystywaliśmy w innych metodach.

Do naszej klasy na pewno dodamy również metodę informowania instancji produktu o tym, że wartość została zmieniona. Dzięki temu w momencie zmiany zamawianej ilości sztuk, cena produktu będzie mogła się natychmiast przeliczyć na nowo. Nie wchodźmy na razie w szczegóły, jak to zrobić.

Na końcu zajmiemy się dodaniem limitów, dzięki którym wybór ilości sztuk będzie ograniczony do zakresu od 1 do 9.

Przygotowanie klasy

Zacznijmy od stworzenia nowej klasy o nazwie AmountWidget, która na początku będzie zawierać wyłącznie konstruktor. Dodaj deklarację tej klasy przed obiektem app.

Tak jak mówiliśmy, będzie on oczekiwać na jeden element, referencję do diva z inputem i buttonami.

Zwijanie kodu i komentowanie

Na tym etapie może Ci już być ciężko z zapamiętaniem, co i gdzie znajduje się w pliku script.js. Niedługo nauczymy się wydzielać klasy do osobnych plików, ale na razie musimy się jeszcze pomęczyć... Dlatego też warto w tym momencie skorzystać z prostszego rozwiązania, zwijania kodu, dostępnego w większości współczesnych edytorów kodu. Zwykle przy najechaniu kursorem na numer linii zobaczysz ikony pozwalające na zwinięcie np. całej funkcji czy metody.

image

W ten sposób możesz zwinąć te metody, którymi w danej chwili się nie zajmujesz, aby uzyskać dużo większą przejrzystość kodu.

Drugą kwestią, która może na tym etapie sprawiać więcej problemów niż dawać korzyści, są użyte przez nas console.log. Jeśli jest ich zbyt wiele, w konsoli będzie sporo komunikatów, pośród których trudno będzie się odnaleźć. Najlepiej będzie w tym momencie znaleźć wszystkie wystąpienia console.log i "zakomentować je", czyli dodać // na początku linii, w której występują. Albo... jeśli zachęciliśmy Cię wcześniej do używania debuggera, w ogóle z nich zrezygnować.

W porządku. Nasza klasa już istnieje (choć w bardzo ubogiej formie). Aby jednak sprawdzić, czy jakkolwiek działa, trzeba ją wykorzystać. Jak? Tworząc instancję. Wiemy że, koniec końców, i tak instancje klasy Product będą chciały korzystać z AmountWidget, licząc na to, że klasa ta "ożywi" ich inputy. Możemy wiec równie dobrze zabrać się za stworzenie takiej współpracy już teraz. Przynajmniej upewnimy się od razu, czy konstruktor naszej nowej klasy poprawnie się włącza.

Wróć więc do klasy Product i odnajdź metodę getElements. Mamy tu już kilka referencji, m.in. do formularza czy diva z ceną. Teraz dodamy kolejną do diva z inputem i buttonami "+" i "-". Po co? Bo jak już wspomnieliśmy, AmountWidget będzie potrzebować dostępu do tego elementu.

Dodaj więc nową właściwość thisProduct.amountWidgetElem. Zadbaj o to, aby jej wartością była referencja do elementu o selektorze select.menuProduct.amountWidget. Pamiętaj przy tym, żeby szukać go w divie pojedynczego produktu, a nie całym dokumencie. Inaczej bowiem moglibyśmy "przypadkiem" znaleźć div z inputem z innego produktu (w końcu każdy div produktu ma identyczną strukturę HTML), a tego nie chcemy.

Następnie, wciąż w klasie Product, dodaj nową metodę initAmountWidget. Będzie ona odpowiedzialna za utworzenie nowej instancji klasy AmountWidget i zapisywanie jej we właściwości produktu. Po to, aby w razie potrzeby mieć do niej łatwy dostęp.

Zauważ, że od razu przekazujemy do konstruktora referencję do naszego diva z inputem i buttonami tak, jak oczekiwała na to klasa AmountWidget. Przy czym powtórzmy jeszcze raz – przekazujemy tylko referencję, tylko adres. Duże obiekty zawsze są przekazywane tylko jako referencja, pamiętasz?

Na koniec, w konstruktorze klasy Product wywołaj tę metodę, tuż przed wywołaniem metody processOrder.

W rezultacie, w konsoli powinny pojawić się komunikaty z console.log użytych w konstruktorze klasy AmountWidget. Pojawią się osobno dla każdego produktu.

Widzimy w tych komunikatach, że żadna z instancji AmountWidget nie ma jeszcze właściwości i każda wyświetla taki sam element. Nie jest to jednak ten sam jeden element, tylko podobny z każdego produktu. Możesz to łatwo sprawdzić, klikając prawym przyciskiem myszy na jednym z elementów wyświetlonych w konsoli i wybierając opcję "Reveal in Elements panel".

image

Znalezienie elementów widgetu

Czas na powrót do klasy AmountWidget. Już wcześniej wspominaliśmy, że będziemy w niej korzystać z trzech elementów – inputu i dwóch buttonów. Warto więc przygotować do nich referencje. Dla czytelności, podobnie jak w klasie Product, zrobimy w nowej dedykowanej metodzie – getElements.

Stworzymy ją teraz. Różnica jest jedna, tym razem będziemy przekazywać tej metodzie argument element otrzymany przez konstruktor. Dlaczego?

W przypadku klasy Product, zanim uruchomimy funkcję getElements, jest wywoływana metoda renderInMenu, która m.in. szykuje nam właściwość thisProduct.element. Dlatego też w getElements możemy od razu z niej korzystać. W końcu, jeśli zapiszemy coś do właściwości instancji, to możemy z tego korzystać w każdej jej metodzie.

W AmountWidget sytuacja jest inna. Nie mamy funkcji renderInMenu, nie szykowaliśmy też wcześniej właściwości thisWidget.element. Tak naprawdę jedynym miejscem, gdzie mamy dostęp do diva otrzymanego w instancji jest argument konstruktora. A czy argument funkcji constructor będzie dostępny ot tak w metodzie getElements? Nie. W końcu argument funkcji jest dostępny tylko w jej zakresie. Tym samym najprościej możemy po prostu przekazać zawartość tego argumentu konstruktora dalej... jako argument kolejnej metody getElements.

Dodaj więc do konstruktora następujące wywołanie:

thisWidget.getElements(element);

A następnie nową metodę:

getElements(element){
  const thisWidget = this;

  thisWidget.element = element;
  thisWidget.input = thisWidget.element.querySelector(select.widgets.amount.input);
  thisWidget.linkDecrease = thisWidget.element.querySelector(select.widgets.amount.linkDecrease);
  thisWidget.linkIncrease = thisWidget.element.querySelector(select.widgets.amount.linkIncrease);
}

Swoją drogą, zauważ jak ważna jest wiedza o referencjach. Argument element, który otrzymaliśmy w konstruktorze jest tylko referencją do tego samego elementu DOM, co thisProduct.amountWidgetElem. Kiedy przekazujemy argument element niżej, do getElements, to dalej przekazujemy tę samą referencję. Czyli tak naprawdę argument element w getElements to wciąż referencja do jednego i tego samego obiektu. Tego samego elementu DOM, na który wskazywał również thisProduct.amountWidgetElem. Idea referencji jest niesamowita, prawda? Gdyby nie ona, mielibyśmy już w tym momencie trzy kopie tego samego obiektu, a tak wciąż działamy na jednym i tym samym. To znacznie wydajniejsze.

Po tym kroku powinny zmienić się komunikaty w konsoli – teraz instancje klasy AmountWidget nie są już puste, tylko mają właściwości, w których zapisaliśmy elementy widgetu.

Przygotowujemy funkcję pośrednik

Użytkownik strony domyślnie może wpisać w inpucie co chce. Przydałoby się, żeby nasz widget na to nie pozwalał. Potrzebujemy jakiejś funkcji pośrednika, która uruchamiałaby się w momencie zmiany wartości, kontrolowała co jest wpisane i dopiero potem decydowała, czy zostawić taką nową wartość, czy może jednak nie. Musi więc również pamiętać jaka wartość była wpisana wcześniej. Wtedy w razie wpisania czegoś błędnego, będzie w stanie przywrócić wcześniejszą poprawną wartość.

Zanim jednak zabierzemy się za taką funkcję na poważnie, dla przypomnienia spójrz na gif, który pokazuje, jakie są nasze oczekiwania:

image

Próba wpisania zbyt dużej wartości (12) lub tekstu (abc) kończy się przywróceniem starej. Zatem na pewno, tak jak mówiliśmy, będziemy musieli stworzyć funkcję, która będzie uruchamiana przy próbie zmiany wartości i decydować, czy ma na to pozwolić, czy może przywrócić starą (ostatnią dobrą) wartość.

Naszą funkcją pośrednikiem będzie nowa metoda – setValue. Dodaj ją teraz do naszej klasy.

To oczywiście dopiero początek. Na razie ta metoda tylko zapisuje we właściwości thisWidget.value wartość przekazanego argumentu, po przekonwertowaniu go na liczbę, a następnie aktualizuje wartość samego inputu. Na razie bez żadnej walidacji nawet nie sprawdzając, czy nie wpisaliśmy czasem czegoś złego... Jednak spokojnie, zaraz się tym zajmiemy. A po co ta konwersja (parseInt)? Pamiętaj, że każdy input, nawet o typie number, zawsze zwraca wartość w formacie tekstowym. Nawet wpisanie więc 10 da nam nie liczbę 10, a tekst '10'. parseInt zadba o konwersję takiej przykładowej '10' do liczby 10.

Tak jak wspomnieliśmy, na razie wpisujemy do thisWidget.value wprost to, co ta metoda otrzyma, ale zaraz to zmienimy. Chcemy jeszcze dodatkowo sprawdzać, czy wartość tej stałej jest poprawna i mieści się w dopuszczalnym zakresie – tylko w takim przypadku zostanie ona zapisana jako właściwość thisWidget.value.

Zaczniemy od najprostszego ifa. Sprawdzimy, czy wartość, która przychodzi do funkcji, jest inna niż ta, która jest już aktualnie w thisWidget.value. Powinien on warunkować, czy linijka thisWidget.value = newValue ma się w ogóle wykonać.

Spróbuj napisać takiego ifa bez naszej pomocy.

/* TODO: Add validation */
if(thisWidget.value !== newValue) {
  thisWidget.value = newValue;
}

To już jakaś zmiana. Teraz thisWidget.value zmieni się już faktycznie tylko wtedy, jeśli nowa wpisana w input wartość będzie inna niż obecna.

W takim razie czas na kolejne ćwiczenie. Chcielibyśmy również, żeby nasza funkcja ustalała, czy to wpisano w input jest faktycznie liczbą. Jak możemy to sprawdzić?

Zwróć uwagę, że na początku naszej funkcji staramy się konwertować podane value do liczby. Jeśli parseInt natrafi na tekst, którego nie da się skonwertować na liczbę (np. abc), to najprościej w świecie zwróci null. Wystarczy więc sprawdzić w naszym warunku, czy oprócz tego, że thisWidget.value !== newValue, newValue nie jest też null-em. No bo tylko jeśli nie jest, możemy mieć pewność, że to liczba i faktycznie zaktualizować wartość thisWidget.value.

Spróbuj dopisać taki warunek bez naszej pomocy. Przyda Ci się znajomość funkcji isNaN. Zajrzyj więc do dokumentacji.

/* TODO: Add validation */
if(thisWidget.value !== newValue && !isNaN(newValue)) {
  thisWidget.value = newValue;
}

To już coś. Wyobraź sobie, jak w tej chwili mogłaby działać nasza funkcja. Powiedzmy, że za pierwszym razem ktoś wpisał w input '3'. Nasza funkcja setValue otrzymałaby taką wartość tekstową, przekonwertowała ją do liczby ('3' -> 3), a następnie sprawdziła warunek. Na samym początku właściwość thisWidget.value nawet nie istnieje, więc na pewno jest inne niż newValue (3). undefined w końcu nie jest równe 3. Czy 3 do tego nie jest NaN-em? No nie jest, więc warunek jest w pełni spełniony! Skoro tak, to od tej chwili thisWidget.value równa się 3 i taką wartość za chwilę dopisujemy też do samego inputu. Co prawda nie było to konieczne, bo użytkownik przecież sam wpisał taką wartość w input, więc i tak już ona tam jest, ale... czy to coś zepsuje? Nie.

Powiedzmy, że za chwilę wpisano kolejną wartość, tym razem celowo niepoprawną, np. abc. Oczywiście znowu uruchomi się nasza funkcja i spróbuje skonwertować wartość do liczby, tym razem jednak zakończy się to porażką. Otrzymamy jako newValue wartość null. W takim razie nasz warunek da nam false i nie wykona instrukcji z ifa. Nie zmieni więc wartości thisWidget.value. Ta wciąż będzie równa 3. Tym samym, kiedy wykona się ostatnia linijka funkcji, a więc przypisanie wartości thisWidget.value do inputu, to tekst abc, który wpisał użytkownik, zostanie nadpisany wartością 3! Dokładnie tak, jak na gifie!

Przyznaj, ciekawe rozwiązanie.

No dobrze, tylko że brakuje nam jeszcze jednego puzzla w układance. Napisaliśmy przed chwilą, że zmiana w inpucie ma włączyć funkcję setValue, ale czy właściwie to robi? Nie. Przecież JS sam z siebie nie domyśli się, o co nam chodzi. Musimy skorzystać z odpowiednich nasłuchiwaczy. Zaraz je dodamy.

Zanim jednak się tym zajmiemy, zrób jeszcze jedną rzecz. Wywołaj tę metodę w konstruktorze, pod wywołaniem metody getElements. Chodzi o to, żeby nawet na samym starcie, kiedy nikt jeszcze nie zmienił wartości w inpucie, nasza instancja miała już informację co w tym inpucie jest. Bo tak naprawdę tam zawsze coś jest. Gdy produkt generuje swój HTML, to w inpucie od razu wstawia nam domyślną wartość. Dobrze, żeby nasz widget o tym wiedział.

Zmiana w tym kroku będzie stosunkowo mała – w komunikatach w konsoli możesz zobaczyć, że teraz każdy widget dodatkowo ma właściwość value równą 1.

Dodanie reakcji na eventy

Czas na nasze nasłuchiwacze. Dodaj kolejną metodę, tym razem nazwij ją initActions. W tej klasie dodamy trzy listenery eventów:

  1. dla thisWidget.input dodaj listener eventu change, dla którego handler użyje metody setValue z takim samym argumentem, jak w konstruktorze (czyli z wartością inputa),
  2. dla thisWidget.linkDecrease dodaj listener eventu click, dla którego handler powstrzyma domyślną akcję dla tego eventu, oraz użyje metody setValue – tym razem argumentem będzie thisWidget.value pomniejszone o 1,
  3. thisWidget.linkIncrease potraktuj tak samo, jak thisWidget.linkDecrease, z tym że argumentem będzie thisWidget.value powiększone o 1.

Następnie zadbaj o to, aby ta metoda uruchamiała się automatycznie, od razu po utworzeniu instancji.

Po wykonaniu ćwiczenia sprawdź jak input ilości sztuk w produkcie reaguje na wpisanie tekstu.

image

Powinien przywracać poprzednią wartość, tak jak na gifie wyżej.

Zakres akceptowanych wartości

W tej chwili możemy zmniejszać ilość sztuk nawet poniżej zera albo wpisywać bardzo duże wartości, nawet w tysiącach. Chcielibyśmy, aby wartości były sprawdzane według jakiegoś zakresu. Nie większe niż maksymalna wspierana wartość i mniejsza niż minimalna. Wymaga to dodania kolejnych dwóch warunków do ifa w metodzie setValue.

Potraktuj to jako kolejne ćwiczenie. Na pewno dasz sobie radę! Minimalna wartość jest dostępna w settings.amountWidget.defaultMin, a maksymalna w settings.amountWidget.defaultMax.

Po zmianach input powinien działać znacznie lepiej:

image

Zauważ, że teraz możemy poruszać się już tylko w zakresie 0-10. Właśnie o to nam chodziło!

Informowanie produktu o zmianie

Jak wspomnieliśmy na początku tego submodułu, nasz widget musi jeszcze w jakiś sposób informować produkt, że zmieniła się liczba sztuk. Tak, aby ten mógł ponownie przeliczyć całkowitą cenę. Moglibyśmy skorzystać z wbudowanego eventu change uruchamianego na inpucie po zmianie jego wartości przez użytkownika strony, ale:

  • jeśli zmieniamy wartość za pomocą JS po kliknięciu w guzik ("+" albo "-"), ten event nie będzie się uruchamiał automatycznie, musielibyśmy go uruchomić ręcznie,
  • jeśli użytkownik wpisze niepoprawną wartość, zostanie uruchomiony event change jeszcze zanim nasz skrypt sprawdzi, czy ta wartość jest poprawna, więc produkt od razu próbowałby przeliczyć cenę... nawet dla wartości niepoprawnej, takiej jak test, a to nie ma prawa się udać.

Dlatego zrobimy coś innego – wywołamy własny, customowy event!

Do tej pory tylko nasłuchiwaliśmy, czy jakiś event się wydarzył – np. czy link został kliknięty. W tym wypadku to akcja użytkownika strony (kliknięcie w link) wywoływała event.

Tym razem sami wywołamy event! Tak, możemy to zrobić. Sami wybierzemy nawet jego nazwę. Dzięki temu produkt będzie mógł nasłuchiwać nie na event change, ale np. update i kiedy go wychwyci, będzie wiedział, że należy zaktualizować cenę produktu, a wartość w inpucie jest na pewno poprawna. Bo na pewno została już sprawdzona.

Wywołanie eventu

Zacznijmy od stworzenia metody announce. Będzie ona tworzyła instancje klasy Event, wbudowanej w silnik JS (czyli w przeglądarkę). Jest to klasa odpowiedzialna właśnie za stworzenie obiektu "eventu". Następnie, ten event zostanie wyemitowany na kontenerze naszego widgetu.

Możesz sobie wyobrazić, że jeśli użytkownik klika gdzieś na stronie, to przeglądarka robi dokładnie to samo co my teraz. Również tworzy event click w podobny sposób przy użyciu klasy Event, a następnie emituje go na tym klikniętym elemencie za pomocą metody dispatchEvent. Ma to sens?

Nasz event nazwaliśmy 'updated', ale to zupełnie zmyślona nazwa – równie dobrze moglibyśmy użyć każdego innego określenia, które nie jest jednym z wbudowanych eventów.

Wywoływanie wbudowanych eventów

Wbudowane eventy również można wywoływać – np. możemy wywołać event click na linku. Nie wywoła to domyślnej akcji (przejścia na adres podany w atrybucie href tego linka), ale zostanie wychwycone przez każdy event listener dodany w JS dla tego linka.

Na przykład, jeśli wywołamy event click na guziku zwiększania ilości, to zadziała on tak samo, jak gdybyśmy go kliknęli. Krótko mówiąc, jesteśmy w stanie sami wchodzić w body "przeglądarki" i symulować nawet wbudowane już w nią eventy.

Teraz musimy jeszcze wywoływać tę metodę announce. Gdzie? Koniecznie musimy zadbać o to, aby uruchamiała się dopiero wtedy, kiedy nowa wartość, którą chcemy ustawić, faktycznie jest poprawna. Tylko wtedy jest sens informować o zmianie produkt. Właśnie tym nasz event updated będzie się różnił od eventu change, że nasz uruchomi się przy zmianie wartości, ale tylko na taką, która wciąż będzie poprawna.

Zastanów się więc, w którym miejscu w metodzie setValue musimy ją wstawić.

Nasłuchiwanie eventu

Drugą częścią informowania produktu, jak już wspomnieliśmy, jest nasłuchiwanie tego eventu w klasie Product. Co bowiem z tego, że event updated będzie emitowany na inpucie, skoro produkt nic sobie z tym nie robi?

Przejdź więc do klasy Product i znajdź w niej metodę initAmountWidget. Następnie dodaj do niej listener eventu, który będzie nasłuchiwał na element thisProduct.amountWidgetElem, na zdarzenie updated. Dlaczego nasłuchujemy właśnie na ten element? Bo to na nim emitowaliśmy nasz event. Pamiętaj, w końcu thisWidget.element to referencja do tego samego identycznego elementu co thisProduct.amountWidgetElem.

Jako funkcja, która ma uruchomić się w momencie wykrycia tego eventu, dodaj prostą funkcję anonimową, która zajmie się uruchamianiem metody thisProduct.processOrder();.

Ten kod już powinien działać, ale nie będzie widać żadnych jego efektów. Nic dziwnego, w końcu metoda processOrder w żaden sposób nie sprawdza wybranej liczby sztuk, ani tym bardziej nie mnoży przez nią ceny końcowej.

Dlatego musimy zrobić jeszcze jedną zmianę. Znajdź metodę processOrder. Na jej końcu powinna być linia kodu, która ustawia zawartość thisProduct.priceElem na wartość zmiennej price. Tuż przed tą linią dodaj ten fragment kodu:

W ten sposób, tuż przed wyświetleniem ceny obliczonej z uwzględnieniem opcji, pomnożymy ją przez ilość sztuk wybraną w widgecie!

Teraz już cena produktu powinna się zmieniać w momencie zmiany ilości. Jeśli klikniesz w guzik zwiększenia lub zmniejszenia ilości, cena zmieni się natychmiast. Jeśli wpiszesz liczbę w inpucie, cena zmieni się, kiedy wyjdziesz z inputa (np. klikniesz gdzieś na stronie lub wciśniesz klawisz Tab na klawiaturze).

Uff... To już koniec. Dobra robota!

image

Zadanie: walidacja wartości

Przed Tobą jeszcze jedno bardzo małe zadanie. Na razie jako wartość startową thisWidget.value wpisujemy domyślną wartość inputu, na którym działamy. Teraz to zmienimy. Zamiast jej, ustawiaj jako wartość domyślną settings.amountWidget.defaultValue.

Oznacza to, że domyślna wartość w inpucie nie będzie już potrzebna. Przejdź więc do pliku index.html i usuń w szablonie produktu value="1" z kodu inputa o atrybucie name="amount". Widget ilości sztuk powinien działać bez zmian, domyślnie pokazując 1. Spróbuj zmienić wartość settings.amountWidget.defaultValue, aby upewnić się, że to właśnie stamtąd jest pobierana domyślna wartość.

Oczekiwany efekt

Jeśli wszystko poszło dobrze, nasz widget wyboru ilości sztuk działa już poprawnie. Pozwala na zmianę wartości za pomocą guzików oraz edycji wartości w inpucie. Jednocześnie pozwala tylko na wybranie wartości od 0 do 10.

Jeśli w inpucie wpiszemy coś innego – np. liczbę spoza tego zakresu albo tekst – to natychmiast po wyjściu z inputa wartość zmieni się na tę, która była ustawiona przed edycją, czyli dotychczasową wartość thisWidget.value.

9.2. Nowe funkcjonalności projektu

Czas na nasz koszyk. Za jego działania będą odpowiadać dwie nowe klasy: Cart oraz CartProduct. Omówmy je sobie po kolei, byśmy wiedzieli, co nas czeka.

Pierwsza z klas, które stworzymy, czyli Cart, będzie wykonywała następujące działania:

  • pokazywanie i ukrywanie koszyka,
  • dodawanie i usuwanie produktów,
  • podliczanie ceny zamówienia.

Ta klasa będzie zatem odpowiedzialna za "globalne" działanie koszyka.

Będzie ona ściśle współpracować z naszą drugą klasą, CartProduct, której instancje będą pojedynczymi produktami w koszyku. Dzięki takiemu podziałowi wszystko, co dotyczy danej pozycji z koszyka, będzie wyodrębnionym kodem. Nie jest to oczywiście "obowiązkowy" podział, ale przekonasz się, że naprawdę będzie to bardzo pomocne.

Kiedy koszyk będzie już w pełni sprawny, zajmiemy się AJAX-em i API – te technologie pozwolą nam na komunikację z serwerem. Na początek posłużą nam do pobierania listy produktów, a następnie do zapisywania złożonych zamówień.

Na końcu tego modułu będziemy mieli już w pełni sprawną stronę pizzerii, w której można nawet składać zamówienia!

Czy to będzie gotowa strona?

Możesz zastanawiać się, czy strona, którą tworzymy, mogłaby być używana przez prawdziwą restaurację. Krótka odpowiedź to: nie.

Po pierwsze, API, którego użyjemy, nie jest w żaden sposób zabezpieczone. Każdy może zobaczyć szczegóły wszystkich zamówień. Używamy go tylko do celów tzw. prototypowania – pozwoli nam napisać i przetestować skrypty JS, służące do komunikacji z serwerem.

Po drugie, nasza strona jest bardzo skąpa – prawdziwa restauracja chciałaby zapewne umożliwić klientom założenie konta, sprawdzenie listy swoich zamówień, czy wyświetlenie informacji o zaakceptowaniu zamówienia. Należałoby też ograniczyć zamówienia do godzin otwarcia pizzerii i być może dodać możliwość płatności online. Kolejnym często spotykanym dodatkiem jest tzw. captcha, czyli zabezpieczenie przed spamem – czyli np. przepisanie kodu z obrazka.

Wreszcie po trzecie, załoga restauracji musiałaby mieć możliwość obsługi tej strony – zmiany produktów w menu, odczytywania listy zamówień, etc. Ten pakiet funkcjonalności zwykle nazywany jest panelem administracyjnym.

Niemniej jednak nasz kod spokojnie mógłby zostać użyty jako baza pod dalszy rozwój i zastosowanie w praktyce. Innymi słowy, właśnie tworzysz projekt, który mógłby być Twoim zadaniem w pracy na stanowisku Junior Frontend Developera!

Przygotowanie do rozwoju projektu

Zanim rozpoczniemy prace, musisz uzupełnić pliki projektu o zmiany, które dla Ciebie przygotowaliśmy. Nie martw się – w większości przypadków wystarczy podmienić parę plików.

Style

Wprowadziliśmy kilka zmian w stylach projektu. Jeśli nie były one przez Ciebie modyfikowane, wystarczy, że rozpakujesz paczkę plików w katalogu src/sass/partials.

Pobierz paczkę styli

Zmian nie ma wiele, a wszystkie oznaczyliśmy komentarzami okalającymi blok nowego kodu:

// CODE ADDED START

// CODE ADDED END

W przypadku zmian pojedynczej linii, na końcu linii dodaliśmy komentarz:

// CODE CHANGED

Zmian jest tylko kilka, więc bez problemu sobie z nimi poradzisz.

Kod HTML

W pliku src/index.html również musimy wprowadzić kilka zmian. Możesz podmienić ten plik na nowy, lub ręcznie wprowadzić zmiany, które wymieniliśmy poniżej.

Pobierz plik index.html


  1. Usuń cały <header>, zamiast niego wstaw <header> z nowego pliku.
  2. Nad szablonem template-menu-product dodaj nowy szablon template-cart-product.
  3. W szablonie template-menu-product znajdź fragment name="amount" i zmienić go na class="amount"

Funkcje JS

W pliku src/js/functions.js wszystkie zmiany to:

  • funkcja pomocnicza: utils.convertDataSourceToDbJson,
  • nowy moduł pomocniczy do szablonów: Handlebars.registerHelper('joinValues', ...,
  • komentarz dla ESLinta w 1. linii pliku.

Wszystkie te zmiany znajdziesz w nowym pliku functions.js

Pobierz plik functions.js

Kod JS

W pliku src/js/script.js musimy dodać kilka zmian w obiektach select, classNames, settings i templates. Poniżej zamieszczamy w całości te obiekty. Możesz podmienić cały fragment kodu, lub przenieść tylko zmiany oznaczone komentarzami.

const select = {
  templateOf: {
    menuProduct: '#template-menu-product',
    cartProduct: '#template-cart-product', // CODE ADDED
  },
  containerOf: {
    menu: '#product-list',
    cart: '#cart',
  },
  all: {
    menuProducts: '#product-list > .product',
    menuProductsActive: '#product-list > .product.active',
    formInputs: 'input, select',
  },
  menuProduct: {
    clickable: '.product__header',
    form: '.product__order',
    priceElem: '.product__total-price .price',
    imageWrapper: '.product__images',
    amountWidget: '.widget-amount',
    cartButton: '[href="#add-to-cart"]',
  },
  widgets: {
    amount: {
      input: 'input.amount', // CODE CHANGED
      linkDecrease: 'a[href="#less"]',
      linkIncrease: 'a[href="#more"]',
    },
  },
  // CODE ADDED START
  cart: {
    productList: '.cart__order-summary',
    toggleTrigger: '.cart__summary',
    totalNumber: `.cart__total-number`,
    totalPrice: '.cart__total-price strong, .cart__order-total .cart__order-price-sum strong',
    subtotalPrice: '.cart__order-subtotal .cart__order-price-sum strong',
    deliveryFee: '.cart__order-delivery .cart__order-price-sum strong',
    form: '.cart__order',
    formSubmit: '.cart__order [type="submit"]',
    phone: '[name="phone"]',
    address: '[name="address"]',
  },
  cartProduct: {
    amountWidget: '.widget-amount',
    price: '.cart__product-price',
    edit: '[href="#edit"]',
    remove: '[href="#remove"]',
  },
  // CODE ADDED END
};

const classNames = {
  menuProduct: {
    wrapperActive: 'active',
    imageVisible: 'active',
  },
  // CODE ADDED START
  cart: {
    wrapperActive: 'active',
  },
  // CODE ADDED END
};

const settings = {
  amountWidget: {
    defaultValue: 1,
    defaultMin: 1,
    defaultMax: 9,
  }, // CODE CHANGED
  // CODE ADDED START
  cart: {
    defaultDeliveryFee: 20,
  },
  // CODE ADDED END
};

const templates = {
  menuProduct: Handlebars.compile(document.querySelector(select.templateOf.menuProduct).innerHTML),
  // CODE ADDED START
  cartProduct: Handlebars.compile(document.querySelector(select.templateOf.cartProduct).innerHTML),
  // CODE ADDED END
};

Łączenie zmian w plikach

Cała ta operacja mogła wydawać się żmudna, ale jest to również sytuacja, z którą możesz spotkać się w swojej przyszłej pracy. Tego rodzaju zmiany możesz otrzymać z opisem podobnym do powyższego, lub jako spory commit wykonany przez innego developera. Przy sporych zmianach, Git może nie poradzić sobie automatycznie ze scaleniem zmian i może wymagać od Ciebie ręcznego pogodzenia konfliktów.

Jeśli zmiany, które należy wprowadzić, nie są jasno oznaczone, najlepiej skorzystać z narzędzia diff (skrót od difference, czyli różnica). Spotkaliśmy się już z tego typu narzędziem – GitHub wyświetla zmiany wprowadzone przez dany commit właśnie jako diff, czyli oznaczając, co dokładnie zmieniło się względem poprzedniego commita. Również edytory kodu często mają wbudowane funkcjonalności porównywania plików.

Jeśli potrzebujesz osobnego narzędzia diff, możesz sprawdzić np. Meld czy P4Merge – pozwalają one nawet na porównywanie całych katalogów.

Możemy zaczynać pracę

Wszystkie zmiany pomogą nam przy implementacji naszego koszyka. Teraz możemy rozpoczynać pracę nad rozbudową projektu!

9.3. Cart – klasa koszyka

Zacznijmy od tego, do czego dążymy. Po zakończeniu pracy w tym module nasz koszyk będzie działać następująco:

image

Jak widzisz, składa się on z wielu elementów: każdy produkt ma nazwę, cenę, widget zmiany ilości, ikony edycji i usunięcia. Dodatkowo, w przypadku produktów z konfigurowalnymi opcjami, wyświetlamy wybrane przez klienta składniki.

Ponadto, górna belka koszyka wyświetla łączną kwotę zamówienia oraz liczbę produktów w koszyku. Dodawanie produktu do koszyka będzie równało się z aktualizacją informacji o koszcie. Będzie również istniała możliwość usuwania produktu z koszyka.

Całość może wyglądać nieco przytłaczająco, ale nie przejmuj się – będziemy przechodzić przez poszczególne elementy po kolei, tak, że zobaczysz, jak łączą się one ze sobą i jak przekazują między sobą dane.

Zaczniemy od zakodowania klasy Cart, która będzie obsługiwała nasz koszyk i wszystkie jego funkcjonalności.

Tworzenie klasy

Na samym początku musimy zadeklarować naszą klasę. Znajdź w kodzie deklarację obiektu app i wstaw przed nią nową klasę – Cart, od razu z konstruktorem i metodą getElements.

image

Jak zwykle stosujemy stałą, w której zapisujemy obiekt this – tym razem nazwiemy ją thisCart. Dodatkowo od razu stworzyliśmy również tablicę thisCart.products, w której będziemy przechowywać produkty dodane do koszyka.

Wprowadzamy tutaj dodatkowo jedną nowość – obiekt thisCart.dom. Nie jest to nic wymaganego, ale znacznie ułatwi nam nawigację po klasie. W poprzednich klasach przypisywaliśmy referencję do elementów DOM od razu jako właściwości instancji (np. thisProduct.amountWidgetElem). Jest to o tyle słaby pomysł, że tak samo przechowywaliśmy również referencję do instancji AmountWidget (thisProduct.amountWidget), czy nawet zwykłe wartości (np. thisWidget.value). Mogło to wprowadzać zamieszanie, np. inny programista, widząc właściwość o nazwie thisCart.totalPrice, mógłby się zastanawiać, czy jest to referencja do elementu HTML, który pokazuje cenę, czy może po prostu liczba? Dzięki temu, że schowamy referencje elementów DOM do osobnego obiektu (thisCart.dom), to łatwiej będziemy w stanie określić rolę poszczególnych właściwości. Widzisz w kodzie thisCart.dom.totalPrice i od razu wiesz, że to musi być element DOM. Widzisz thisCart.totalPrice i masz pewność, że to coś innego.

Nie jest to oczywiście jakaś wymagana praktyka, lecz zwykły pomysł, który powinien uczytelnić nam trochę naszą klasę.

Ćwiczenie

Spróbuj wprowadzić ten sam pomysł w klasie Product. Tak, żeby wszystkie referencje do elementów DOM były "schowane" w obiekcie dodatkowym obiekcie thisProduct.dom.

Tworzenie instancji

Mamy już naszą klasę, musimy jeszcze stworzyć jej instancję. Oczywiście w naszej aplikacji będzie tylko jeden koszyk, a więc wykorzystamy tę klasę tylko raz. Odnajdź obiekt app i stwórz w nim metodę initCart. Zadbaj o to, aby inicjowała instancję koszyka. Pamiętaj, że konstruktor tej klasy oczekuje na przekazanie referencji do diva, w którym ten koszyk ma być obecny. Przekażemy jej więc wrapper (czyli kontener, element okalający) koszyka.

image

Pozostaje jeszcze wywołać tę metodę na końcu app.init, aby w konsoli zobaczyć komunikat generowany przez console.log na końcu konstruktora klasy Cart.

image

Jak widzisz, instancję klasy Cart zapisaliśmy w thisApp.cart. Oznacza to, że poza obiektem app możemy wywołać ją za pomocą app.cart. Już za chwilę będziemy z tego korzystać, aby zacząć dodawać produkty do koszyka!

Po co nam klasa dla jednego koszyka?

Możesz zastanawiać się, czy podejście obiektowe ma sens, kiedy będziemy tworzyć tylko jedną instancję danej klasy. Oczywiście, moglibyśmy zastosować inną architekturę w tym wypadku, ale zastosowanie OOP nie ma żadnych wad. Nie napiszemy przez to więcej kodu, bo te same funkcjonalności i tak musielibyśmy oskryptować.

Zaletą tego podejścia jest natomiast uporządkowanie kodu – zarówno myśląc o jakości kodu, jak i naszej wygodzie (czy szerzej – wygodzie pracy developera). Pamiętaj, że edytor kodu pozwala na zwijanie kodu, dzięki któremu znacznie łatwiej będzie Ci odnaleźć odpowiedni fragment pliku.

image

Niektóre edytory, jak np. darmowy Visual Studio Code, wyświetlają nawet outline, czyli "spis treści" pliku!

image

Dodatkowo, otwieramy sobie drogę do przyszłych rozwiązań. Może w przyszłości restauracja będzie chciała umożliwić zapisanie koszyka i późniejsze wczytanie jednego z zapisanych koszyków? Albo zechce umożliwić zamówienia grupowe, w których zamówienie będzie się składać z kilku koszyków poszczególnych osób, aby mogły rozliczyć się osobno? Łatwiej nam będzie wprowadzić takie funkcjonalności, jeśli koszyk będzie stworzony jako instancja klasy.

Zadanie: pokazywanie i chowanie koszyka

Pierwszą funkcjonalnością koszyka będzie jego pokazywanie i ukrywanie. Przypomina Ci to coś? Dokładnie to samo robiliśmy już w klasie Product, z tą różnicą, że tam potrzebowaliśmy jednocześnie ukrywać inne "otwarte" produkty. Tutaj mamy tylko jeden element, jeden koszyk, będzie więc jeszcze prościej!

1. W metodzie getElements dodajemy definicję właściwości thisCart.dom.toggleTrigger, która znajduje w thisCart.dom.wrapper pojedynczy element o selektorze zapisanym w select.cart.toggleTrigger.

2. Dodajemy metodę initActions i wywołujemy ją w konstruktorze tuż pod wywołaniem metody getElements.

3. W metodzie initActions deklarujemy thisCart i dodajemy listener eventu 'click' na elemencie thisCart.dom.toggleTrigger.

4. Handler tego listenera ma toggle'ować klasę zapisaną w classNames.cart.wrapperActive na elemencie thisCart.dom.wrapper.

Oczekiwany efekt

W rezultacie koszyk powinien się rozwijać i zwijać przy kliknięciu, pokazując/ukrywając szczegóły koszyka, zawierające m.in. guzik "ORDER".

image

9.4. Dodawanie produktów do koszyka

Jeżeli udało Ci się wykonać poprawnie poprzednie zadanie, koszyk na stronie Twojej pizzerii już się otwiera. Ciągle jednak świeci on pustkami – czas to zmienić!

Założenia funkcjonalności

Kiedy użytkownik wybierze w menu jakiś produkt, ustawi jego opcje i kliknie w guzik "ADD TO CART", to nic się na razie nie stanie. Musimy wprowadzić zmiany w klasie Product, dzięki którym po kliknięciu tego guzika, koszyk (czyli app.cart) zostanie poinformowany o tym, że ma dodać do swojej listy nowy produkt.

Wysłanie produktu do koszyka

1. Zaczynamy od dodania w klasie Cart nowej metody:

image

Zakomentowaliśmy pierwszą linię, aby ESLint nie zgłaszał błędu, że ta stała nie została jeszcze nigdzie wykorzystana. Poza tym w funkcji znajduje się tylko console.log wyświetlający argument przekazany tej metodzie.

2. W klasie Product również dodaj nową metodę:

image

Jak widzisz, przekazuje ona całą instancję jako argument metody app.cart.add. Pamiętasz, że w app.cart zapisaliśmy instancję klasy Cart, prawda? Dlatego w ten sposób odwołujemy się do jej metody add. Właśnie tej, którą przygotowaliśmy chwilę wcześniej, w pierwszym punkcie.

Może Cię dziwić, że "przekazujemy instancję", ale to nie znaczy, że ona gdzieś wędruje – metoda add otrzyma tylko odwołanie (referencję) do tej instancji, dzięki czemu będzie mogła odczytywać jej właściwości i wykonywać jej metody. W poprzednim kroku widzimy, że w metodzie add ta instancja produktu będzie dostępna jako menuProduct.

3. W klasie Product znajdź metodę initOrderForm, a w niej handler eventu 'click' na elemencie thisProduct.cartButton. Na końcu handlera, pod wywołaniem metody processOrder, dodaj wywołanie metody addToCart. Pamiętasz? Obiecaliśmy, że tu jeszcze wrócimy :)

Analiza dostępnych danych

Po wykonaniu tych operacji, kliknięcie w ADD TO CART któregokolwiek produktu powinno wyświetlić w konsoli instancję tego produktu.

Przyjrzyjmy się tej instancji i zastanówmy się, czy aby na pewno to dobrze, że przekazaliśmy ją w całości, no i czy czasem czegoś nie brakuje. Przeprowadźmy tę analizę na podstawie tego, czego możemy potrzebować. Spójrz jeszcze raz na oczekiwany efekt:

image

Czego więc potrzebujemy? Na pewno nazwy produktu, ceny całkowitej, ilości sztuk, informacji jakie opcje wybrano oraz ceny jednostkowej. Po co nam cena jednostkowa? Chcemy, aby w koszyku, była możliwość zmiany liczby sztuk. Dobrze byłoby więc, aby koszyk wiedział, ile kosztuje pojedyncza sztuka, tak, aby łatwo mógł przeliczyć cenę całkowitą od nowa. Dodatkowo dobrze byłoby też posiadać id produktu. To bardziej dodatek na przyszłość. Nasz serwer będzie bowiem oczekiwał takiej informacji. Nie wchodźmy na razie w szczegóły, ale zwyczajnie musimy taką właściwość przygotować.

Spróbuj więc testowo dodać do koszyka jakiś produkt. Kliknij na button "Add to cart" np. w pizzy i spójrz na to, co otrzymaliśmy:

image

Co na pewno tutaj mamy?

  • jest id produktu, które będziemy wysyłać do serwera przy zapisaniu zamówienia,
  • jest data.name, które da nam nazwę produktu,
  • jest amountWidget, która zawiera właściwość value (da nam ona informacje o liczbie sztuk).

Jakich informacji nam brakuje?

  • ceny pojedynczego produktu z wybranymi opcjami, która będzie nam potrzebna, aby obliczać nową cenę, jeśli liczba sztuk zostanie zmieniona już w koszyku,
  • zestawienia wybranych opcji, ponieważ je też chcemy wyświetlać w koszyku oraz wysyłać przy zapisaniu zamówienia.

Drugi punkt może Cię dziwić, bo zapewne widzisz w naszej instancji obiekt params. Dlaczego nie możemy skorzystać właśnie z niego? Dobrze mu się przyjrzyj. On tylko mówi nam, jakie kategorie i opcje dany produkt posiada. Nie mówi jednak, czy w danej sytuacji były one wybrane, czy nie. A bez tej informacji jest on dla nas bezużyteczny.

Na pewno będziemy musieli więc zadbać o dodanie tych dwóch informacji.

Dodatkowo warto zauważyć przy okazji jeszcze jedną rzecz. Tak naprawdę przekazujemy do koszyka również mnóstwo zbędnych informacji. Spójrz tylko na takie właściwości jak imageWrapper, form, formInputs, cartButton itd. Zresztą, taka instancja posiada nawet metody, jak processOrder czy getElements. Nie widzisz ich "na wierzchu", ale są schowane pod właściwością __proto__. Po co nam takie rzeczy w koszyku?

Musimy wziąć to pod uwagę. W takiej sytuacji zamiast przekazywać całą instancję produktu, może warto byłoby po prostu... przygotować nowy mniejszy obiekt. Taki, które będzie miał tylko te właściwości z instancji, które są nam faktycznie potrzebne plus te dwie, których nam brakuje. Przy okazji będziemy mogli też ułatwić dostęp do data.name czy amountWidget.value. Nasz nowy obiekt może np. zapisać je od razu pod właściwością name czy amount!

Zapisanie danych zamawianego produktu

Wiemy, co chcemy zrobić, czas więc zabrać się do pracy. Zacznij od utworzenia nowej metody w klasie Product. Nazwij ją prepareCartProduct i standardowo zacznij od przygotowania "skrótu" do this. Stwórz również nowy, na razie pusty obiekt o nazwie productSummary. Słowo summary (podsumowanie) dobrze oddaje, czym ten obiekt będzie. Minimalnym podsumowaniem całego produktu. Takim, które posiada tylko niezbędne dla koszyka informacje.

image

Podstawowe informacje

Zacznijmy od dodania do tego obiektu najprostszych informacji. Dodaj więc właściwości id, name, amount. Wszystkie powinny posiadać informacje wzięte wprost z instancji thisProduct. id powinno być równie zapisanej thisProduct właściwości id (thisProduct.id), name powinno być równe zapisanej tam nazwie itd.

Cena jednostkowa i cena całkowita

Musimy teraz ustalić ceny. Potrzebujemy informacji o cenie jednostkowej i cenie całkowitej. Ta druga będzie zwyczajnie wynikiem pomnożenia ceny jednostkowej przez liczbę sztuk.

Wynika więc z tego, że musimy dowiedzieć się jakoś o wartości ceny jednostkowej, wtedy przygotowanie drugiej będzie już tylko formalnością.

Może wydawać Ci się, że w thisProduct mamy już zapisaną cenę jednostkową, ale to nie prawda. thisProduct.data.price to bowiem cena startowa, niebiorąca pod uwagę, że klient mógł dokonać jakichś zmian w opcjach produktu, a przecież zanim klient doda produkt do koszyka, to faktycznie może coś w tych opcjach zmieni. Co z tego wynika? Że musimy taką cenę ustalić...

Zapewne czytając powyższy akapit, w głowie zaświtał Ci już pewien pomysł. Przecież przeliczamy już cenę biorącą pod uwagę wybrane opcje. Robimy to w metodzie proccesOrder! Przeliczamy tam cenę i zapisujemy wartość do HTML-a. Na razie w żaden sposób nie aktualizujemy z jej wartością thisProduct, ale możemy zacząć to robić!

Wystarczy więc właśnie w tej metodzie dodać linijkę, która będzie po obliczeniach dodawała do thisProduct nową właściwość np. (priceSingle), do której od razu zapiszemy aktualnie przeliczoną wartość.

Ćwiczenie

Odnajdź w metodzie processOrder miejsce, w którym aktualizujesz cenę w HTML-u. Przed tą linijką dodaj instrukcję, która wyposaży thisProduct w nową właściwość priceSingle. Przypisz do niej wartość tej samej ceny, którą zapisywaliśmy też w HTML-u.

Od tej chwili, każdorazowe uruchomienie processOrder będzie równało się z aktualizacją thisProduct.priceSingle. A skoro wiemy, że ta metoda uruchamia się przy każdorazowej zmianie jakiejś opcji, to możemy być pewni, że thisProduct.priceSingle będzie zawsze zwracała aktualną cenę jednostkową! Sprytnie, prawda?

Wróć więc teraz do metody prepareCartProduct i dodaj do naszego obiektu dwie nowe właściwości priceSingle i price.

priceSingle powinno wskazywać na wartość ceny jednostkowej dostępnej w thisProduct (thisProduct.priceSingle). Tej, o którą przed chwilą zadbaliśmy. Druga właściwość, price, powinna być za to ceną całkowitą, a więc ceną jednostkową pomnożoną przez liczbę sztuk.

Opcje produktu

Nieźle, została nam już tylko jedna rzecz – przygotowanie dostępu do wybranych opcji. Tym zajmiesz się za moment w ramach zadania. Na razie w obiekcie productSummary przygotuj tylko nową właściwość params. Póki co, niech będzie pustym obiektem.

Na końcu dodaj do naszej metody słowo kluczowe return, w taki sposób, aby funkcja zwracała właśnie nasz cały obiekt podsumowania. Teraz wystarczy wykorzystać ją w metodzie addToCart. Zamiast przekazywać do thisApp.cart.add cały obiekt thisProduct, przekazuj to, co zwróci właśnie metoda thisProduct.prepareCartProduct.

Teraz próba dodania produktu do koszyka powinna zakończyć się pokazaniem w konsoli znacznie mniejszego obiektu. Taki właśnie jest nasz plan. Koszyk ma otrzymać obiekt tylko z tyloma informacjami, ile naprawdę potrzebuje.

image

Musisz przyznać, że wygląda to znacznie lepiej. No i przede wszystkim koszyk nie otrzymuje zbędnych informacji.

Przekazanie obiektu z opcjami będzie już Twoim zadaniem. Nie bój się jednak, nie będzie ono wcale takie trudne, jak mogłoby się wydawać.

Ćwiczenie

Stwórz nową metodę w klasie Product i nazwij ją prepareCartProductParams. Jej zadaniem powinno być przejście po wszystkich kategoriach produktu, następnie po ich opcjach, sprawdzenie czy są one wybrane i wygenerowania podsumowania w formie małego obiektu.

Jego końcowa struktura będzie mniej więcej taka:

image

Co możemy wyczytać z tego screenu? Chcemy, aby nasza funkcja na pewno zwróciła nowy obiekt. Każda kategoria (param) powinna być w nim nową właściwością. Na przykładzie jest nią ingredients. Właściwość taka powinna być nowym obiektem, który posiada dwie własne właściwości: label z nazwą kategorii (param) oraz obiekt options z opcjami, które są w danej kategorii zaznaczone.

Każda z opcji w tym obiekcie powinna być właściwością, której klucz (nazwa) to id opcji (np. feta), a wartość to jej pełna nazwa (np. Feta cheese).

Zauważ, że funkcja ta będzie bardzo podobna do metody processOrder. Również będziemy musieli mieć dostęp do formularza, aby mieć informacje, jakie opcje są wybrane. Również będziemy musieli przejść po wszystkich kategoriach i po każdej z opcji w nich dostępnych. Dla każdej opcji będziemy musieli tak samo sprawdzić, czy jest wybrana, czy nie. Tylko że teraz zamiast ceny, będziemy po prostu tworzyć obiekt, który ma być "podsumowaniem" wybranych opcji.

Spróbuj na spokojnie zastanowić się jak można to "ugryźć". Najlepiej zacznij od skopiowania zawartości metody processOrder do prepareCartProductParams i zastanowienia się, jak musimy ją zmodyfikować. Przypomnijmy, że wynikiem jej działania ma być zwrócenie nowego obiektu z podsumowaniem wybranych opcji. Czyli jeśli w pizzy wybrano by np. jako toppings opcje salami i olives, a jako sauce opcję tomato, to funkcja powinna zwrócić taki obiekt:

{
  toppings: {
    label: 'Toppings',
    options: {
      salami: 'Salami',
      olives: 'Olives',
    }
  },
  sauce: {
    label: 'Sauce',
    options: {
      tomato: 'Tomato'
    }
  }
  ...
}

Poniżej zapisaliśmy kilka wskazówek. Każda kolejna odkrywa coraz więcej kodu. Postaraj się jednak nie zaglądać do nich dopóki naprawdę nie będziesz mieć już żadnego pomysłu. W swojej pracy wspomagaj się również debuggerem i console.log.

Pierwsze kroki

Zaczniemy od małej podpowiedzi, od czego warto zacząć.

Po skopiowaniu zawartości metody processOrder:

  1. Zacznij od pozbycia się zbędnego kodu, a więc wszystkiego, co związane z price. Zarówno utworzenia stałej, jak i zwiększania/zmniejszania ceny, czy też końcowego kodu, który zajmował się ustalaniem priceSingle oraz pokazywaniem wartości w HTML-u. Nasza nowa metoda w żaden sposób nie ma się bowiem zajmować ceną.
  2. Usuń cały kod odpowiedzialny za pokazywanie/chowanie obrazka reprezentującego daną opcję.
  3. Uprość ify, które sprawdzają, czy opcja jest wybrana. Tak naprawdę teraz nie interesuje nas to, czy opcja była domyślna, czy nie. Wystarczy sprawdzenie, czy była wybrana, czy nie. Jeśli tak, to musimy zawrzeć ją w obiekcie podsumowania. Jeśli nie, to nie.
  4. Dodaj przed oboma pętlami nowy pusty obiekt. Nazwij go np. params. Zadbaj też o to, aby funkcja zwracała go na końcu. To właśnie do tego obiektu powinny być dodawane kolejne właściwości opisujące kategorie.

Teraz pozostaje Ci już tylko zadbanie o to, aby params otrzymywało po drodze odpowiednie właściwości kategorii, oraz żeby każda z nich otrzymywała informacje o opcjach, które są w nich zawarte.

To za mało?

Jeśli pierwsza podpowiedź do dla Ciebie za mało, to poniżej możesz znaleźć naszą metodę już po krokach z poprzedniej wskazówki, wraz z małym dodatkiem – kodem, który dodaje do params nową właściwość dla każdej kategorii!

prepareCartProductParams() {
  const thisProduct = this;

  const formData = utils.serializeFormToObject(thisProduct.form);
  const params = {};

  // for very category (param)
  for(let paramId in thisProduct.data.params) {
    const param = thisProduct.data.params[paramId];

    // create category param in params const eg. params = { ingredients: { name: 'Ingredients', options: {}}}
    params[paramId] = {
      name: param.label,
      options: {}
    }

    // for every option in this category
    for(let optionId in param.options) {
      const option = param.options[optionId];
      const optionSelected = formData[paramId] && formData[paramId].includes(optionId);

      if(optionSelected) {
        // option is selected!
      }
    }
  }

  return params;
}

Teraz pozostaje Ci już tylko dodanie do pętli warunkowej sprawdzającej, czy opcja jest wybrana, kodu, który doda do params[paramId].options konkretną opcję.

Teraz mamy już funkcję, która potrafi zaoferować nam zgrabnie podsumowanie opcji aktualnie wybranych w produkcie.

Bez stresu

Możliwe, że wymyślenie, jak powyższa funkcja ma działać, nie było dla Ciebie zbyt łatwe i konieczne było wspomożenie się wskazówką/wskazówkami. Nie musisz się z jednak tego powodu stresować. Pisanie funkcji od zera, znajdowanie podobieństw i radzenie sobie z tak dużymi projektami, jak nasza pizzeria, przychodzi z czasem. Z większą ilością praktyki. Jeśli więc, koniec końców, jesteś w stanie wytłumaczyć, jak ta funkcja teraz działa, to spokojnie możesz iść dalej. Więcej na tym etapie od Ciebie nie oczekujemy.

Pozostaje nam teraz wykorzystać tę funkcję.

Ćwiczenie

Nasza metoda jest już gotowa, ale funkcja prepareCartProduct wciąż jej nie wykorzystuje. Musimy to zmienić. Zmodyfikuj funkcję prepareCartProduct tak, aby jako wartość params ustawiała to, co zwraca metoda prepareCartProductParams.

Na końcu sprawdź, czy wszystko działa poprawnie. Spróbuj ponownie dodać do koszyka pizzę i sprawdź, co otrzyma nasz koszyk.

Powinien otrzymywać mniej więcej coś w tym stylu:

image

Inna droga

Może Cię zastanawiać jeszcze jedna rzecz. Czy nie dałoby się jakoś połączyć metod processOrder i prepareCartProductParams w jedną? W końcu są bardzo podobne. Odpowiedź jest prosta – dałoby się. Dzięki temu nie powtarzalibyśmy się drugi raz z podobnym kodem. Dlaczego więc tego nie zrobiliśmy?

Na etapie nauki, wydajność kodu czy też maksymalne jego skracanie nie jest najważniejsze. Znacznie istotniejsza jest po prostu praktyka. Dzięki temu, że wymagaliśmy od Ciebie stworzenia zupełnie nowej metody, ale podobnej do processOrder byliśmy w stanie przetrenować naszą wiedzę po raz drugi. Dzięki czemu zapewne i sama oryginalna metoda stała się dla Ciebie znacznie czytelniejsza.

Zadanie: generowanie elementów DOM

Mamy już wszystkie niezbędne informacje, teraz pozostaje jeszcze wykorzystać je do wygenerowania i dodania kodu HTML do koszyka. W rezultacie, kliknięcie buttona "ADD TO CART" powinno wyświetlać produkt w koszyku!

Zacznij od dodania do metody getElements koszyka nowej referencji. Pamiętamy o zdefiniowaniu thisCart.dom.productList powinien być równy odpowiedniemu elementowi z HTML-a. Dokładnie liście produktów. Odpowiedni selektor znajdziesz w stałej select.

Następnie przejdź do znalezienia metody Product.renderInMenu. Tak naprawdę robi ona praktycznie to samo, czego będziemy oczekiwać po metodzie Cart.add. Generuje element DOM. Wtedy był to cały duży div produktu. Teraz będzie to mały div podsumowania produktu, który będzie znajdować się w koszyku. Idea jest jednak taka sama. Chcemy wygenerować jakiś element na podstawie szablonu. Mocno będziemy się więc na niej opierać.

Wejdź więc do metody Cart.add i opierając się na metodzie Product.renderInMenu:

  1. Za pomocą odpowiedniego szablonu stwórz kod HTML i zapisz go w stałej generatedHTML. Jako obiekt z danymi dla szablonu, wykorzystaj oczywiście nasz z podstawowymi informacjami o produkcie obiekt otrzymany w argumencie.
  2. Następnie ten kod zamień na element DOM i zapisz w następnej stałej – generatedDOM.
  3. Dodaj ten element DOM do thisCart.dom.productList (użyj metody appendChild)

Oczekiwany efekt

W koszyku powinien pojawić się produkt, który zawiera poprawne informacje – liczbę, nazwę, opcje, cenę (za wszystkie sztuki) oraz guziki edycji i usuwania.

image

Pamiętaj, że widget liczby sztuk i guziki jeszcze nie działają – właśnie tym zajmiemy się za chwilę!

9.5. CartProduct – klasa pozycji w koszyku

Musimy teraz pomyśleć trochę o przyszłości. O tym, jak będzie wyglądało wysyłanie zamówienia. Serwer na pewno będzie musiał być w stanie zapisać dokładne informacje o zamówieniu. Jakie produkty były wybrane, z jakimi opcjami i za ile. Dlatego też nasz koszyk na pewno powinien być mu je w stanie dostarczyć.

Nasz koszyk na razie dostaje informacje o produkcie, który chcemy dodać, a te są przekazywane dalej, do metody add. Jest to cały obiekt podsumowania z nazwą, id itd. Tak naprawdę wykorzystujemy go jednak tylko jednorazowo do wygenerowania element HTML-u, który ma nasz produkt reprezentować (konkretnie do naszej listy w HTML-u) i to... tyle.

Tak naprawdę JS nigdzie nie zapisuje tych danych "na później", a przecież będziemy ich potrzebować! W końcu ktoś kliknie na przycisk "order" i w takiej sytuacji będzie musieli utworzyć jakieś podsumowanie wybranych produktów, aby wysłać je do serwera. Jak to zrobimy nie mając nigdzie w JS-ie informacji, co było kiedyś wybrane? Tak naprawdę jedynym wyjściem byłoby przejście po elementach HTML-u i próbowanie wyczytać z nich odpowiednie informacje... Trochę mało profesjonalny pomysł, nie sądzisz?

Możemy to jednak łatwo naprawić. Zauważ, że w naszej klasie koszyka istnieje już tablica products. Czeka na nas właśnie po to, aby rozwiązać nasz problem. Wystarczy ją wykorzystać!

Jeśli każdorazowo przy dodawaniu produktu do koszyka, będziemy zapisywać obiekt jego podsumowania do tablicy thisCart.products, to będzie ona dla nas swego rodzaju podsumowaniem. Kiedy tylko będziemy mieli taką ochotę, będziemy mogli wejść do tej tablicy i sprawdzić, jakie aktualnie elementy są w naszym koszyku, włącznie z dokładnymi informacjami na ich temat, takich jak cena czy liczba sztuk.

Możesz przetestować ten pomysł samodzielnie. Na końcu metody Cart.add dodaj te dwie linie kodu:

image

Następnie na stronie dodaj do koszyka 1 sztukę sałatki, a potem 5 sztuk tej samej potrawy.

image

Jak widzisz, nie było to takie trudne!

Na tym etapie warto może jednak się zatrzymać. Za chwilę będziemy zajmować się kolejnymi funkcjonalnościami, które będą się tyczyć konkretnie samych koszykowych produktów. Mamy tu na myśli dodanie obsługi widgetu ilości sztuk czy dodanie obsługi buttonu "usuń". Czy tego typu rzeczy naprawdę powinny znajdować się w klasie Cart? Czy tyczą się całego koszyka? Nie. Tak naprawdę tyczą się konkretnych produktów w nim zawartych. Znacznie czytelniej byłoby więc wyciągnąć funkcjonalności produktu w koszyku do osobnej klasy. A że pomoże to nam w utrwaleniu wiadomości na temat klas i instancji, to właśnie tym tropem teraz podążymy! Każdy produkt w koszyku będzie u nas nową instancją specjalnej klasy CartProduct! To ona będzie odpowiedzialna za funkcjonowanie pojedynczej pozycji w koszyku.

Podsumowując, chcemy, aby klasa Cart zajmowała się całym koszykiem, jego głównymi funkcjonalnościami, a klasa CartProduct pojedynczymi produktami, które się w nim znajdują.

Tworzenie klasy

Spróbuj samodzielnie poradzić sobie ze stworzeniem klasy CartProduct. Dodaj ją pod klasą Cart, czyli tuż przed deklaracją obiektu app.

Zacznij od konstruktora.

Ćwiczenie 1

Jakie informacje będą potrzebne do funkcjonowania klasy CartProduct? Na pewno musi wiedzieć, jaki produkt ma w danej chwili obsługiwać oraz mieć dostęp do jego reprezentacji w HTML-u, którą stworzyła klasa Cart.

konstruktor powinien więc przyjąć dwa argumenty: menuProduct oraz element. Pierwszy będzie przyjmował referencję do obiektu podsumowania, a drugi referencję do utworzonego dla tego produktu elementu HTML-u (generatedDOM).

Wewnątrz konstruktora zdefiniuj stałą thisCartProduct i zapisz w niej obiekt this. Następnie postaraj się zapisać w nim wszystkie właściwości z argumentu menuProduct. Przypisz je do pojedynczych właściwości, np. thisCartProduct.id = menuProduct.id itd. Oczywiście robimy to tylko dla naszej wygody.

Następnie zadbaj o to, aby konstruktor wykonał metodę getElements, przekazując jej argument element. Dodaj również console.log wyświetlający thisCartProduct.

Ćwiczenie 2

Czas utworzyć wspomnianą wcześniej metodę getElements przyjmującą argument element.

W samej metodzie:

  • zdefiniuj stałą thisCartProduct i zapisz w niej obiekt this,
  • stwórz pusty obiekt thisCartProduct.dom,
  • stwórz właściwość thisCartProduct.dom.wrapper i przypisz jej wartość argumentu element (wiemy, że to referencja do oryginalnego elementu DOM),
  • stwórz kolejnych kilka właściwości obiektu thisCartProduct.dom i przypisz im elementy znalezione we wrapperze; te właściwości to: amountWidget, price, edit, remove (ich selektory znajdziesz w select.cartProduct).

Efektem Twojej pracy powinna być wyświetlana w konsoli (w momencie dodania do koszyka) instancja klasy CartProduct.

image

Dużo podobieństw...

Możesz zauważyć, że nasze klasy są do siebie często podobne. Każda korzysta np. z idei zapisywania this pod "wygodniejszą" nazwą. Każda używa również metody o nazwie getElements, aby przygotować referencje do elementów w HTML. Czy to jakieś ogółem przyjęte praktyki?

W żadnym wypadku. Tak naprawdę równie dobrze moglibyśmy korzystać z samego "gołego" this albo nazwać metodę do przygotowania referencji do HTML prepareDOMRefs. To kwestia naszego wyboru. Jeśli już jednak zdecydujemy się na jakieś nazwy czy praktyki, to najlepiej trzymać się ich w całym projekcie. Dzięki temu znacznie łatwiej będzie Ci nawigować po klasach w przypadku błędów, czy też rozwoju aplikacji. Dlatego też staramy się tego trzymać podczas pracy nad naszym projektem.

Tworzenie instancji

Wróć teraz do metody Cart.add i znajdź linię:

thisCart.products.push(menuProduct);

Zamień w niej menuProduct na new CartProduct(menuProduct, generatedDOM). W ten sposób jednocześnie stworzymy nową instancję klasy new CartProduct oraz dodamy ją do tablicy thisCart.products. Dobrze wiesz, że dzięki temu będziemy mieli stały dostęp do instancji wszystkich produktów. Właśnie poprzez tę tablicę. Bardzo wygodne, prawda?

Wykonaj teraz ponownie test, który robiliśmy wcześniej – dodaj do koszyka 1 sałatkę, a następnie 5 sałatek. Sprawdź ostatni komunikat w konsoli. Ponownie mamy tablicę z dwoma elementami, ale nie są to już zwykłe obiekty, a instancje klasy CartProduct. Owszem, wciąż posiadają te same startowe właściwości (id, name itd.), ale również kilka nowych. Mają również własne metody!

Właśnie o to nam chodziło! W dłuższym rozrachunku naprawdę docenisz ten podział.

Obsługa widgetu ilości sztuk

Jak widzisz, produkty w koszyku mają swoje własne widgety liczby sztuk. Co więcej, kiedy najedziesz na nie kursorem, zobaczysz że mają też guziki z plusem i minusem. Do ich działania wykorzystamy klasę AmountWidget, którą stworzyliśmy w poprzednim module!

image

Ćwiczenie

Wzorując się na metodzie Product.initAmountWidget, spróbuj stworzyć analogiczną w klasie CartProduct. Całość będzie bardzo podobna. Zadbaj o to, aby stworzyć nową instancję tej klasy (AmountWidget), przekazując jej odpowiedni element, na którym ma pracować. Nie zapomnij również o dodaniu nasłuchiwacza. Sama funkcja do niego przekazywana może być jednak na razie pusta.

Zadbaj też o to, aby ta metoda była uruchomiana w konstruktorze klasy CartProduct. Chcemy bowiem, aby wywoływała się od razu po utworzeniu instancji.

Czas zająć się zawartością handlera eventu. Co powinno się stać, kiedy użytkownik zmieni liczbę sztuk danej pozycji w koszyku? Zakładając, że na razie nie interesuje nas koszyk jako całość, tylko ta jedna pozycja w koszyku?

Na pewno musimy na nowo ustawić wartości dla dwóch właściwości, początkowo ustawianych w konstruktorze – thisCartProduct.amount i thisCartProduct.price. Następnie musimy wyświetlić na stronie nową przeliczoną cenę tego produktu (z uwzględnieniem liczby sztuk).

Widzisz? Właśnie o tym mówiliśmy wcześniej. Po to przekazywaliśmy właściwość priceSingle, żeby takie obliczenie było możliwe i stosunkowo łatwe do przeprowadzenia. Nie potrzebujemy informacji o formularzu, czy cenach opcji, które zostały wybrane. Mamy od razu gotową cenę pojedynczej sztuki z konkretnymi opcjami i wystarczy zadbać o to, aby była ona pomnożona przez zmienioną liczbę sztuk. Właśnie to da nam nową wartość thisCartProduct.price.

Ćwiczenie

Zadbaj o to, aby funkcja w nasłuchiwaczu (nasz handler) poprawnie aktualizowała wartość thisCartProduct.amount oraz thisCartProduct.price.

Powinna również aktualizować kwotę widoczną w samej reprezentacji HTML-a tego produktu. To będzie jednak dość proste zadanie. Na tym etapie cena w thisCartProduct.price będzie już zaktualizowana. Wystarczy więc znaleźć referencję do odpowiedniego elementu w HTML i zaktualizować jego wartość. Przeszukaj w tym celu metodę getElements. Na pewno jest już tam przygotowana odpowiednia referencja.

Pamiętaj, że aktualna wartość widgetu (czyli liczby sztuk) jest dostępna pod odpowiednią właściwością thisCartProduct.amountWidget

Teraz widget liczby sztuk powinien już działać dla każdej pozycji dodanej do koszyka. Co więcej, cena tej pozycji powinna zmieniać się w zależności od wybranej liczby produktów!

image

Sumowanie koszyka

Zanim zajmiemy się wyświetlaniem sum cen i liczby pozycji w koszyku – to dobry moment, aby ponownie usunąć wszystkie wystąpienia console.log. Dzięki temu będziemy mogli dalej pracować z czystą konsolą.

Oczywiście teraz będzie pracować na całym koszyku. Wracamy więc do klasy Cart.

Nasz koszyk zawiera cztery informacje:

  1. suma liczby sztuk zamawianych produktów,
  2. cena "Subtotal", czyli suma cen pozycji w koszyku,
  3. koszt dostawy,
  4. cena "Total", czyli suma "Subtotal" oraz ceny dostawy. Jest ona wyświetlana dwa razy: w nagłówku koszyka oraz w jego podsumowaniu.

Musimy zadbać o odpowiednią aktualizację tych informacji. Naszym zadaniem musi być więc stworzenie nowej metody. Takiej, która przechodząc po kolei po wszystkich produktach, wyliczy nam wszystkie kwoty.

Ćwiczenie

Stworzenie tej metody będzie Twoim zadaniem.

Dodaj do klasy Cart metodę update i – jak zwykle – zadeklaruj w niej stałą thisCart.

Zacznij od przygotowania stałej z informacją o cenie dostawy. Nazwij ją deliveryFee i nadaj jej wartość zapisaną w odpowiedniej właściwości obiektu settings.

Następnie dodaj dwie kolejne stałe totalNumber i subtotalPrice. Pierwsza będzie odpowiadała całościowej liczbie sztuk, a druga zsumowanej cenie za wszystko (chociaż bez kosztu dostawy). Każdej z nich przypisz startowo wartość 0.

W kolejnym kroku dodaj pętle for...of, która przejdzie po thisCart.products. Zadbaj o to, aby zwiększała totalNumber o liczbę sztuk danego produktu. Podobnie zwiększ również subTotalPrice o jego cenę całkowitą (właściwość price).

Po zamknięciu pętli zapisz dodatkową właściwość koszyka – thisCart.totalPrice. Jej wartością ma być nasza cena całkowita, czyli kwota potrzebna do kupna wszystkich produktów z koszyka i koszt dostawy.

Uwaga! Pamiętaj, że jeśli w koszyku nie ma ani jednego produktu, to nie ma sensu w cenie końcowej wliczać deliveryFee. Nie ma produktów, więc nie ma dostawy, czyli nie ma kosztów dostawy. Wtedy cena końcowa powinna być a po prostu równa zero. Dlatego na pewno przy ustalaniu thisCart.totalPrice nie obędzie się bez jakiegoś ifa, który sprawdzi, czy w ogóle jest sens doliczać deliveryFee.

Dlaczego tym razem, zamiast tworzyć nową stałą, przypisujemy ją jako właściwość? Z prostego powodu. Stałe są dostępne tylko w danym zakresie. W tym przypadku w zakresie funkcji update. Właściwości są za to dostępne w całej instancji. Tym samym możemy je używać również w innych metodach. deliveryFee czy totalNumber nie będzie nam potrzebne "na zewnątrz", dlatego są one stałymi. totalPrice jednak będziemy już używać w innej metodzie. Tej, która będzie odpowiedzialna za wysyłkę danych do serwera. Musimy więc mieć do niej dostęp na zewnątrz.

Oczywiście możesz się teraz zastanawiać, skąd masz to wiedzieć na tym etapie. Odpowiedź jest prosta – nie musisz. Tak naprawdę, gdybyśmy na razie skorzystali ze zwykłej stałej, nic by się nie stało. Później, podczas pisania funkcji służącej do wysyłania danych do serwera, i tak zauważylibyśmy potrzebę dostępu do tej informacji. Nic nie stałoby na przeszkodzie, żeby dopiero wtedy zrobić z naszej stałej właściwość. My wyprzedzamy teraz trochę przyszłe potrzeby, ale tylko dlatego, że wiemy, jak całość na końcu będzie wyglądać. Nie oczekujemy jednak podobnego przewidywania od Ciebie. To nie jest jeszcze ten etap nauki, w którym mielibyśmy prawo tego oczekiwać.

Na końcu zadbaj o to, aby konsola pokazała Ci wszystkie stałe oraz właściwość totalPrice. Pozwoli Ci to na przetestowanie, czy całość działa poprawnie.

Zanim jednak zabierzesz się za testy, dodaj jeszcze wywołanie metody update na końcu metody add! Tak, aby każdorazowe dodanie produktu, faktycznie odpalało tę metodę. Dopiero wtedy sprawdź, czy po każdym dodaniu produktu do koszyka zmieniają się wartości tych właściwości.

Pamiętaj – na razie obsługujemy tylko dodawanie nowych pozycji. Zmiana liczby sztuk produktu w koszyku jeszcze nie będzie powodować zmiany sum!

Wyświetlenie aktualnych sum

Pozostaje nam jeszcze wyświetlenie aktualnych sum. Żeby było to możliwe, musimy jednak zadbać o to, aby nasza klasa miała dostęp do odpowiednich elementów w HTML. Tych, które pokazują cenę, liczbę sztuk itd.

Ćwiczenie

W metodzie getElements dodaj do obiektu dom cztery nowe właściwości:

  1. deliveryFee – powinna być referencją do elementu pokazującego koszt przesyłki.
  2. subTotalPrice – powinna być referencją do elementu pokazującego cenę końcową, ale bez kosztów przesyłki.
  3. totalPrice – powinna być referencją do elementów pokazujących cenę końcową.
  4. totalNumber – powinna być referencją do elementu pokazującego liczbę sztuk.

Teraz możemy wrócić do naszej metody update. Póki co, pokazuje ona wszystkie informacje, ale w konsoli. Musimy zadbać to, aby pokazywała je w HTML-u.

Ćwiczenie

Wróć do metody update i zadbaj o to, aby odpowiednio aktualizowała ona HTML naszego koszyka. W taki sposób, aby użytkownik widział poprawną liczbę sztuk, cenę subTotal oraz całkowitą, a także koszt dostawy. Przy czym, jeśli w koszyku jest zero produktów, to koszt dostawy powinien być równy zero.

Efekt powinien być następujący:

image

Teraz wszystkie kwoty zamówienia widoczne w koszyku powinny się aktualizować każdorazowo po dodaniu nowego produktu.

Pozostaje jeszcze jedna kwestia – sumy nie zmieniają się po zmianie liczby sztuk, a powinny.

Aktualizacja sum po zmianie ilości

Chcielibyśmy, aby zmiana ilości sztuk w instancji CartProduct również uruchamiała metodę update, ale mamy pewien problem. Zmiana liczby sztuk zachodzi w klasie CartProduct, a metoda update znajduje się w klasie Cart.

Wiesz już, że Cart ma w miarę łatwy dostęp do instancji CartProduct. Np. w metodzie update bez problemu dochodziliśmy do price pojedynczych produktów. W końcu instancje klasy CartProduct to, koniec końców, po prostu obiekty. Gorzej jednak z komunikacją w drugą stronę...

Wbrew pozorom jednak, rozwiązanie w naszej sytuacji będzie akurat bardzo proste. Wykorzystamy do tego event, który jest już generowany przez AmountWidget w instancjach CartProduct. Musimy go jednak nieco zmodyfikować. Znajdź klasę AmountWidget.announce i zmień tę linię:

const event = new Event('updated');

na ten kod:

image

Co tu się właściwie stało? Używamy teraz innego rodzaju eventu, którego właściwości możemy kontrolować. W tym wypadku włączamy jego właściwość bubbles, która ma bardzo ciekawe działanie. Bez bubbles event jest emitowany tylko na jednym elemencie, na tym, na którym odpalamy dispatchEvent. Z opcją bubbles, ten event będzie nadal emitowany na tym elemencie, ale również na jego rodzicu, oraz dziadku, i tak dalej – aż do samego <body>, document i window. Czy pamiętasz termin bąbelkowanie (propagacja)? Event click bąbelkuje domyślnie, dzięki czemu jest przekazywany od klikniętego elementu do rodzica. W przypadku customowego eventu bąbelkowanie musimy włączyć sami, do czego właśnie przyda nam się właśnie wbudowana w JavaScript właściwość bubbles.

To o tyle ważne, że bardzo ułatwi nam sprawę.

Pomyśl tylko. Sam fakt, że na widgecie w CartProduct ta akcja jest emitowana to już dużo. Moglibyśmy bowiem dojść w Cart do każdego produktu z osobna i przypiąć własny nasłuchiwacz do jego właściwości amountWidget. Wtedy po zmianie liczby sztuk w obojętnie którym produkcie, Cart by o tym wiedział. Taki scenariusz wymagałby jednak dodania po jednym nasłuchiwaczu dla każdego produktu. 20 produktów w koszyku równałoby się np. potrzebie przygotowania aż 20 nasłuchiwaczy... Możesz domyślić się, że nie jest to zbyt wydajny pomysł.

Kiedy jednak wiemy, że event emitowany w pojedynczym produkcie, będzie emitowany również wyżej, do całej listy produktów, całego diva koszyka itd., to możemy to rozegrać inaczej. Ustawiając jeden event na sama listę produktów.

Pomyśl tylko. Nieważne, czy zmienimy liczbę sztuk w inpucie produktu pierwszego czy drugiego, najpierw emitowany będzie event w divie widgetu w danym produkcie, ale potem i tak zostanie wyemitowany na elemencie wyżej, całej liście. Nie musimy więc nasłuchiwać w koszyku pojedynczo na input każdego produktu. Wystarczy, że będziemy nasłuchiwać na całą listę (ul z produktami).

Dzięki temu możemy teraz w metodzie Cart.initActions dodać taki kod:

image

Nasłuchujemy tutaj na listę produktów, w której umieszczamy produkty, w których znajduje się widget liczby sztuk, który generuje ten event. Dzięki właściwości bubbles "usłyszymy" go na tej liście. Jest dla nas informacja, że w "którymś" z produktów doszło do zmiany ilości sztuk. Nieważne nawet w którym. Ważne jest to, że w takiej sytuacji należy uruchomić update, aby ponownie przeliczyć kwoty.

Jeśli wszystko poszło dobrze, liczby poszczególnych produktów i wszystkich cen w koszyku już się aktualizują, nie tylko przy dodawaniu nowych produktów, ale także przy zmianie ich liczby.

image

Ten submoduł kończymy bez zadania. I jak? Daliśmy radę!

9.6. Usuwanie produktu z koszyka

Koszyk na stronie naszej pizzerii działa już coraz lepiej – sumy przeliczają się po dodaniu produktu oraz przy zmianie liczby sztuk. Teraz zajmiemy się usuwaniem produktu z koszyka. Wykorzystamy do tego CustomEvent, tak samo, jak zrobiliśmy to przed chwilą, nasłuchując eventu updated generowanego przez AmountWidget.

Wywołanie eventu

Zacznijmy od dodania w klasie CartProduct nowej metody:

image

Podobnie jak w AmountWidget, wykorzystujemy tutaj CustomEvent z właściwością bubbles. Dodatkowo jednak wykorzystujemy właściwość detail. Możemy w niej przekazać dowolne informacje do handlera eventu. To ważna informacja. Kiedy bowiem emitowaliśmy event informujący o zmianie liczby sztuk, to np. Cart nie interesowało, co dokładnie się zmieniło. Sam fakt, że event się wyemitował, był wystarczający. Teraz jednak Cart będzie musiało wiedzieć, co dokładnie trzeba usunąć. W tym przypadku przekazujemy więc wraz z eventem dodatkowo odwołanie do tej instancji, dla której kliknięto guzik usuwania.

detail może więc rozumieć jako "szczegóły", które mają być przekazywane wraz z eventem.

Ta metoda jeszcze nie jest nigdzie wykorzystywana – zajmiemy się tym teraz. Stwórz więc w klasie CartProduct kolejną metodę (initActions) i wywołaj ją w konstruktorze. W tej metodzie stwórz dwa listenery eventów 'click': jeden dla guzika thisCartProduct.dom.edit, a drugi dla thisCartProduct.dom.remove. Oba mają blokować domyślną akcję dla tego eventu. Guzik edycji na razie nie będzie niczego robił, ale w handlerze guzika usuwania możemy dodać wywołanie metody remove.

To już wszystkie zmiany, jakie musimy wykonać w klasie CartProduct. Możesz dodać console.log do metody remove, aby sprawdzić, czy jest wywoływana po kliknięciu guzika.

Zadanie: wychwycenie eventu

Nasz CartProduct powinien już wysyłać event remove – teraz musimy sprawić, aby Cart wychwycił ten event i odpowiednio zareagował.

W metodzie Cart.initActions dodaj listener eventu remove. Ma on działać analogicznie do listenera eventu updated, czyli obserwować element productList.

W handlerze eventu będzie tylko jedna linia kodu – wywołująca metodę thisCart.remove (którą za chwilę napiszemy). Zadbaj o to, aby jako argument przekazywać jej wartość event.detail.cartProduct. Pamiętasz, jak wywołując event, zawarliśmy w nim odwołanie do instancji thisCartProduct? Właśnie w ten sposób (event.detail.cartProduct) teraz ją odbieramy i przekazujemy do metody thisCart.remove.

Ćwiczenie

Stworzenie tej metody będzie już Twoim zadaniem.

Metoda Cart.remove powinna przyjmować jeden argument (właśnie naszą instancję produktu).

Jej zadaniem jest:

  1. Usunięcie reprezentacji produktu z HTML-a,
  2. Usunięcie informacji o danym produkcie z tablicy thisCart.products.
  3. Wywołać metodę update w celu przeliczenia sum po usunięciu produktu.

Do wykonania zadania konieczne jest wykorzystanie wbudowanych w JS funkcji. Wszystkie informacje o nich znajdziesz poniżej:

W rezultacie, kliknięcie guzika usuwania przy którejkolwiek pozycji w koszyku, powinno:

  • usunąć tę pozycję z koszyka,
  • wyświetlić sumy (liczby i cen) obliczone bez usuniętego produktu.

Końcowy efekt powinien być następujący:

image

9.7. AJAX i API – wprowadzenie

Za nami sporo pracy! Kiedy zerkniesz na stronę swojej pizzerii, przekonasz się, że udało Ci się zaimplementować kilkanaście złożonych funkcjonalności. Strona potrafi wyświetlać produkty oraz ich opcje, użytkownik może konfigurować zamówienie, a koszyk na stronie dynamicznie przelicza ceny produktów. Jak na ten etap to bardzo dużo!

Jednakże dotychczas wszystkie informacje na temat produktów są zapisane "na sztywno" w statycznym pliku (data.js). Jak może już się domyślasz, to rozwiązanie nie jest najlepsze. Dużo bardziej profesjonalny i efektywny sposób to przechowywanie danych na serwerze i pobieranie ich dynamicznie do klienta, kiedy zajdzie taka potrzeba. Już za moment nauczymy się, jak się do tego zabrać – pomocne nam będą dwa bardzo obecnie popularne narzędzia, czyli AJAX i API. Żaden dobry frontend developer nie może się bez nich obyć!

Frontend vs Backend

Zanim przejdziemy jednak do szczegółów, wyjaśnijmy, czym w ogóle jest serwer.

Tak naprawdę serwer to, najprościej mówiąc, jakiś komputer udostępniający użytkownikowi lub użytkownikom (klientom) swoje zasoby. Zasobami mogą być po prostu pliki, bazy danych, albo np. strony internetowe.

image

Serwer może mieć bardzo różne zastosowania. Istnieją serwery FTP (serwery plików), poczty, telnet (zdalna obsługa komputera), czy WWW. Nas interesuje właśnie ten ostatni, który pozwala na serwowanie stron internetowych oraz prostą komunikację za pomocą protokołu HTTP.

Może Ci się wydawać, że to zupełna nowość, ale to nieprawda. Zauważ, że kiedy uruchamiamy naszą stronę za pomocą task runnera (a dokładnie paczki browser-sync), to uruchamiamy już jeden serwer! Serwer dostępny pod adresem localhost:3000 i udostępniający naszą stronę (plik index.html). Owszem, nie tworzyliśmy go sami, całą robotę brała na siebie paczka browser-sync, ale już z takiego korzystaliśmy. Jedyną nowością, która się więc za chwilę pojawi, jest to, że na razie tworzyliśmy serwer, który tylko uruchamia zwykłą stronę internetową, a teraz stworzymy drugi, znacznie ciekawszy, który będzie zajmował się udostępnianiem danych!

Cienka granica

Podział pomiędzy frontedenem i backendem jest już od dłuższego czasu bardzo rozmyty. Wcześniej ta granica była dość mocno zarysowana. Backend zajmował się całą logiką aplikacji oraz kontaktem z bazą danych, a frontend obsługiwał tylko komunikację z użytkownikiem. Wraz z rozwojem JS-a, sytuacja uległa zmianie. Na tym etapie kursu wiesz, że nawet bez wykorzystania serwera jesteśmy w stanie zbudować całkiem skomplikowane aplikacje. Faktycznie JS jest teraz o wiele bardziej funkcjonalny, co wpływa na zmianę balansu – coraz częściej frontend zajmuję się większością logiki aplikacji, a backend pełni drugorzędną funkcję. Bardzo często sprowadza się ona jedynie do roli pośrednika do bazy danych. W takiej sytuacji mówimy o serwerach API.

Taką rolę będzie pełnił również nasz nowy serwer. Nie możemy komunikować się z bazą danych wprost z poziomu klienta (naszej strony internetowej). Serwer jednak ma już taką możliwość. Dlatego też będziemy wykorzystywać nasz nowy serwer właśnie jako pośrednika do bazy danych. Chcemy pobrać dane? Wysyłamy prośbę (request) do serwera, ten łączy się z bazą, pobiera takie dane i zwraca je nam w odpowiedzi (response). Chcemy zmienić coś w bazie? Może np. dodać jakieś dane? Musimy połączyć się z serwerem (request) i powiedzieć mu co chodzi. Serwer połączy się z bazą, doda dane, a następnie zwróci nam informacje o sukcesie czy porażce (response).

image

Spójrz na powyższy schemat. Powinien Ci trochę bardziej rozjaśnić ten pomysł.

Pamiętaj jednak, że nie zawsze musi tak być. Wciąż istnieje mnóstwo aplikacji i stron, w których główną rolę odgrywa serwer. Tak działają np. wszystkie blogi oparte na popularnym systemie Wordpress.

Co to jest AJAX?

No dobrze. Wiemy już, że klient nie ma wprost dostępu do bazy danych. Dlatego też łączy się z serwerem API i dopiero ten, jako pośrednik, się z nią komunikuje. Następnie zwraca odpowiedź, którą otrzyma, klientowi. Jednak właściwie jak klient (strona internetowa) może wykonać taki request? Odpowiedź brzmi AJAX.

Wśród młodych developerów AJAX często traktowany jest jak czarna magia, której opanowanie wymaga nie lada zdolności. Nic bardziej mylnego! AJAX nie jest trudny ani skomplikowany – za chwilę zobaczysz, jak za pomocą kilku linijek kodu możemy komunikować się z serwerem.

AJAX (Asynchronous JavaScript and XML) jest asynchronicznym zapytaniem do serwera. Słowo asynchroniczny oznacza, że nasz kod JS może dalej pracować podczas oczekiwania na odpowiedź serwera.

O co chodzi? W tradycyjnym podejściu (bez AJAX-a), kiedy użytkownik np. klika link na stronie lub wysyła formularz, przeglądarka przekazuje zapytanie do serwera. Serwer w odpowiedzi odsyła nowy HTML, który przeglądarka wyświetla w miejsce poprzedniego. Skutkuje to dobrze Ci znanym przeładowaniem strony, by nowe dane mogły być wyświetlone.

AJAX umożliwia wysyłanie zapytań do serwera, a jednocześnie dalsze, nieprzerwane działanie aplikacji. Dzięki temu możemy np. dynamicznie podmieniać treści na stronie, bez konieczności przeładowywania jej w całości.

Czyli np. uruchamiamy request do serwera, który ma pobrać produkty. Nie czekamy jednak aż ten się zakończy, tylko pozwalamy JS-owi pójść dalej i np. renderujemy koszyk. Kiedy request po jakimś czasie zakończy się sukcesem i serwer zwróci nam jakąś odpowiedź, to JS wróci do tematu i zajmie się naszymi produktami. Zobacz jednak, że nie czekał bezczynnie na zakończenie requestu, połączenia z serwerem, tylko w międzyczasie robił "coś innego".

Jest to o tyle ważne, że serwery nie zawsze będą w stanie odpowiedzieć nam szybko. Weź pod uwagę, że często obsługują miliony zapytań od różnych klientów, a to może wpływać negatywnie na ich wydajność. Np. łatwo możemy wyobrazić sobie, że serwery Facebooka obsługują miliony zapytań na minutę. Im więcej ich jest, im więcej użytkowników na raz korzysta ze strony, tym gorzej serwer będzie sobie radził i wolniej zwracał odpowiedź. Na pewno zdarzyła Ci się taka sytuacja, że w momencie promocji w jakimś sklepie internetowym i dużego obłożenia przez klientów, strona potrafiła działać wolniej. Właśnie dlatego, że serwer to zwykły komputer. Im słabszy jego procesor, im mniejsze ma "moce przerobowe", tym gorzej będzie sobie radził z natłokiem zapytań. Tym samym zmuszanie JS-a do "czekania", w momencie, który mógłby wykorzystać do robienia czegoś innego, mija się z celem.

Metafora – AJAX

Jeśli jeszcze nie do końca to rozumiesz, to wspomóż się poniższą metaforą.

Wyobraź sobie kiosk na przystanku autobusowym.

Podróżni często kupują w tym kiosku bilety, bo na przystanku nie ma automatu do biletów.

Dawniej sprzedawca w kiosku codziennie wychodził na godzinę zjeść obiad w pobliskiej restauracji. W tym czasie podróżni często jeździli na gapę, bo w pobliżu nie było innego kiosku.

Teraz jednak jest inaczej – sprzedawca z kiosku zamawia jedzenie z dostawą, dzięki czemu nie musi przerywać pracy. Robi sobie tylko 5 minut przerwy, kiedy dostawca przywiezie mu jedzenie.

Podobną zmianę wprowadza nam AJAX – kod JS może nadal działać, użytkownik może dalej korzystać ze strony, podczas gdy strona wysyła jakieś "zamówienie" do serwera, którym zajmie się dopiero w momencie otrzymania odpowiedzi.

Warto podkreślić, że AJAX nie jest osobną technologią lub frameworkiem. Jest to pewna filozofia myślenia o aplikacjach internetowych, koncentrująca się na dynamicznej interakcji z użytkownikiem. Żądania (requesty) AJAX-owe mogą być pisane m.in. w znanym Ci już języku JavaScript.

Po co kontaktować się z serwerem?

Rozumiesz już zapewne, o co chodzi w komunikacji asynchronicznej, ale wciąż możesz zastanawiać się, czy jest konieczna? Nie jest, ale przeważnie będzie naprawdę dobrym wyborem. Zalet jest kilka i powinny Cię przekonać. Jedną z nich jest estetyka – np. przy doładowywaniu większej ilości treści na stronie, strona może pokazać animowane przejście do nowej zawartości, a kiedy wysyłamy formularz, strona może wyświetlić komunikat o błędzie bez usuwania treści wpisanych do formularza.

Co więcej, użytkownik może korzystać ze strony, nie czekając na odpowiedź z serwera. Dzięki temu np. na stronach Facebooka czy Gmaila nie musisz odświeżać strony, aby zobaczyć powiadomienie o nowej wiadomości. A kiedy wysyłasz wiadomość, możesz natychmiast wysłać kolejną, nie czekając na potwierdzenie wysłania poprzedniej. Redukuje to konieczność żmudnego klikania i oczekiwania, aż strona przetworzy żądanie i wyświetli nowe treści.

Trzeba też zwrócić uwagę na kwestię optymalizacji działania strony – Gmail wczytywałby się bardzo długo, gdyby miał od razu w treści strony mieć zawarte wszystkie Twoje maile. Dużo sprawniej działa, kiedy może wczytać dany mail dopiero w momencie, kiedy chcesz go przeczytać. Dzięki temu sama strona aplikacji może być lekka i szybko się wczytywać, a treści mogą być ładowane i pokazywane dopiero, kiedy są potrzebne.

Czyli, podsumowując: asynchroniczna komunikacja z serwerem, poprzez ograniczenie liczby przeładowań i skrócenie czasu oczekiwania na wczytanie nowych treści, znakomicie wpływa na użyteczność (usability) naszych stron oraz odciąża serwer.

Co możemy wysyłać i odbierać?

Technicznie rzecz biorąc – wszystko. Możesz np. za pomocą AJAX-a pobrać kod HTML innej podstrony, aby jego fragment wstawić na obecnie wyświetlanej stronie.

W większości wypadków jednak, chodzi o pobranie z serwera jakich danych. Dlatego też komunikacja odbywa się w jednym z dwóch formatów: XML lub JSON. Pierwszy z nich jest podobny do kodu HTML, ale jest on coraz rzadziej stosowany, a drugi mocno przypomina obiekty JS-owe. Dlatego też w naszym wypadku powinien być to znacznie wygodniejszy wybór. Skupimy się więc na tym formacie.

JSON

Ten format bardzo przypomina zwykłe obiekty i tablice w kodzie JS. Jedyną poważną różnicą jest to, że nazwy właściwości muszą być opakowane cudzysłowem, a same dane powinny być proste. Mówiąc proste, mamy na myśli, że mogą być to liczby, teksty, obiekty, tablice, ale np. funkcje już nie.

JSON to tak naprawdę nic innego, jak po prostu ciąg znaków (string, tekst), który jest sformatowany bardzo podobnie do tablic i obiektów w JS. Przykładowy obiekt w JSON wygląda tak:

{
  "books" : [
    {
      "author": "Moore, Kate",
      "title": "The Radium Girls",
      "genre": "History"
    },
    {
      "author": "Madeline, Miller",
      "title": "Circe",
      "genre": "Fantasy"
    },
    {
      "author": "King, Stephen",
      "title": "Elevation",
      "genre": "Horror"
    }
  ]
}

Skoro wiemy, że będziemy komunikować się z serwerem za pomocą tego formatu, to musimy od razu powiedzieć, jak takie dane ew. konwertować. W końcu np. jeśli otrzymamy od serwera listę produktów w formacie JSON, to chcielibyśmy skonwertować je do zwykłej tablicy.

Do konwertowania JSON-a na tablice/obiekty i odwrotnie używamy biblioteki JSON. Zobacz to na przykładzie w naszym poradniku.

Czym jest API?

Często mówiąc o serwerze, który pełni rolę pośrednika do bazy danych, mówimy serwer API. Dlaczego API? Skąd ta nazwa?

API wcale nie tyczy się tylko serwer. Mówiąc API mamy na myśli po prostu zbiór metod danego programu/aplikacji, które można wywoływać spoza niego. Brzmi to tajemniczo, ale łatwo możemy to wytłumaczyć.

Wyobraź sobie np. system obsługi kamery w przeglądarce. Jak wiesz, takowy istnieje i jest wykorzystywany przez wiele serwisów. Czy tak naprawdę, gdybyśmy chcieli w JS korzystać z kamery, to czy interesuje nas, jak cały system jest zbudowany? Nie. To, czego oczekujemy, to po prostu zestaw jakichś prostych metod, za pomocą których bylibyśmy w stanie uruchamiać kamerę, czy renderować jej obraz do jakiegoś diva. Przeglądarka faktycznie zbiór takich metod udostępnia. W takiej sytuacji moglibyśmy nazwać je np. Camera API. Podobnie zestaw metod do obsługi mikrofonu również moglibyśmy nazwać API.

Wynika z tego, że mówiąc API mamy na myśli po prostu jakiegoś pośrednika, który za pomocą prostych komend/metod jest w stanie uruchamiać znacznie większe i bardziej interesujące operacje.

W takim wypadku serwer, który jest pośrednikiem do bazy danych, a więc udostępnia endpointy (adresy), które pozwalają na połączenie z nią, jak najbardziej pasuje do tej definicji. Stąd też tak często używana nazwa serwer API.

Idąc dalej tym tropem, warto powiedzieć, że bardzo często nie interesuje nas to, jak serwer jest zbudowany pod maską. Obchodzi nas tylko to jakie endpointy (adresy) udostępnia i co pod nimi wykonuje. Jeśli to wiemy, to możemy bardzo łatwo ustalić, jaki request należy wykonać, aby spodziewać się pożądanych efektów. Najczęściej więc autorzy serwera udostępniają dokumentację, która opisuje wszystkie dostępne endpointy.

Możesz zastanawiać się, czy jeden endpoint może służyć tylko do jednego celu. To świetne pytanie, na które zaraz odpowiemy!

Metody zapytań

Przy komunikacji AJAX-owej mamy do wyboru tzw. metody zapytań. Dzięki nim ten sam endpoint może wykonywać różne akcje.

Podstawową metodą jest GET – tę metodę stosujesz bardzo często, nawet o tym nie wiedząc! Kiedy wpiszesz adres strony w przeglądarce i wciśniesz enter, do serwera zostanie wysłane właśnie zapytanie GET. Drugą, bardzo popularną metodą zapytania jest POST, służąca do wysyłania danych do serwera.

W praktyce stosuje się je w taki sposób, że np. zapytanie GET do endpointa /order powinno zwrócić listę wszystkich zamówień. Jeśli zaś zmienimy metodę na POST, serwer będzie oczekiwał, że prześlemy mu dane nowego zamówienia.

Oczywiście na tym etapie kursu nie interesuje nas to, jak serwer jest zbudowany pod maską. Ważne jest tylko to, że łącząc się z nim pod wybranym adresem i przy użyciu konkretnej metody, wykona dla nas jakieś operacje, np. zwróci dane o produktach albo zapisze do swojej tablicy z danymi nowy.

Podsumowanie

Za chwilę wprowadzimy AJAX w naszym projekcie. Na pewno w praktyce znacznie łatwiej będzie Ci zauważyć, jak bardzo pozytywnie wpłynie na działanie naszej strony. Będziemy kontaktować się z naszym własnym, testowym API. Stworzymy w nim dwa endpointy – /product z którego za pomocą GET odczytamy listę produktów, a następnie /order, do którego za pomocą POST będziemy wysyłać składane zamówienia.

9.8. Pobieranie listy produktów

Aby wypróbować AJAX-a w praktyce, potrzebujemy serwera, z którym będzie komunikować się nasza strona. W przypadku komercyjnego projektu serwer byłby specjalną dedykowaną aplikacja stworzoną przez zespół Backend Developerów, ale do naszych potrzeb wystarczy znacznie prostsze rozwiązanie. Potrzebujemy serwera, który udostępni nam zbiór endpointów, umożliwiających komunikację z bazą danych.

Wspomożemy się specjalną paczką – json-server. Jej działanie jest stosunkowo proste. Musimy poinformować ją z jakiego pliku .json z danymi ma skorzystać, a ona sama przygotuje nam serwer z odpowiednimi endpointami, które pozwolą nam na skuteczną komunikację z tymi danymi.

Przykładowo, jeśli przekażemy jej jako dane taki plik:

{
  names: ['John', 'Amanda', 'Thomas']
}

...to paczka ta stworzy nam serwer z m.in. następującymi endpointami:

  • GET /names – zwracałoby tablicę ['John', 'Amanda', 'Thomas']
  • POST /names – pozwalałoby na dodawanie do tablicy nowego elementu

Próba połączenia z naszej strony z endpointem /names przy użyciu metody GET zakończyłaby się otrzymaniem w odpowiedzi tablicy ['John', 'Amanda', 'Thomas'], którą moglibyśmy wykorzystać do wyrenderowania listy imion na stronie. Łącząc się z tym samym adresem, ale przy użyciu metody POST, bylibyśmy za to w stanie wprowadzić do bazy danych (do tablicy z imionami) nowe imię!

Oczywiście tych endpointów byłoby nawet więcej. Pojawiłby się również endpoint do edycji czy do usuwania imion. Rozumiesz, już jednak pewnie jaki jest pomysł na działanie tej paczki. Przekazujemy jej plik z danymi, a ona tworzy dla nas serwer, który pozwala na komunikację z tymi danymi przy użyciu endpointów.

My oczywiście przekażemy znacznie ciekawszy plik z danymi. Będą tam produkty, zamówienia, rezerwacje itd. json-server stworzy więc dla nas znacznie więcej endpointów, ale będą one działały analogicznie.

Zauważ, że to działanie, o którym pisaliśmy już wcześniej. Nasz serwer ma pełnić rolę pośrednika. Połączenie z odpowiednim endpointem (adresem), przy użyciu konkretnej metody, ma powodować określone działanie, np. zwracanie danych czy ich zmianę. Nie interesuje nas, jak dokładnie serwer wykonuje te operacje. My będziemy tylko wykonywać request i oczekiwać na response (odpowiedź).

Nie bój się, jeśli całe zagadnienie wciąż nie jest dla Ciebie do końca jasne. Zaraz zobaczysz, jak to działa w praktyce.

Co przed nami

Zanim zabierzemy się do pracy, zastanówmy się, co mamy do zrobienia:

  1. Pobranie paczki json-server.
  2. Przygotowanie nowego tasku server w task-runnerze, który przy użyciu json-server i podanych danych będzie dbał o uruchomienie serwera z odpowiednimi endpointami.
  3. Pozbycie się w naszym kliencie (stronie internetowej) bezpośredniego dostępu do danych (dataSource).
  4. Zadbanie o to, aby klient, zaraz po uruchomieniu strony, łączył się z serwerem i za jego pomocą pobierał dane o produktach.

Zauważ, że końcowy efekt działania naszej strony będzie taki sam, tylko że teraz to serwer będzie dbał o dostarczanie odpowiednich danych.

Przechowywanie danych poza aplikacją ma wiele zalet. Przede wszystkim możemy skonfigurować serwer w taki sposób, aby np. nie zawsze zwracał wszystkie dane. Może np. powinien pozwalać na pobieranie listy użytkowników, tylko jeśli jesteśmy zalogowani? Albo zwracał tylko część potrzebnych danych, takich które nie są "wrażliwe" (czyli nie hasła, nie PESEL itd.). Teraz z poziomu klienta mamy dostęp do całego pliku. Każdy użytkownik mógł zobaczyć wszystkie dane. Wystarczy wejść do zakładki Sources w DevToolsach... Dedykowany serwer to więc znacznie większe bezpieczeństwo, jak i możliwość personalizacji zwracanych danych.

Durga sprawa to na pewno centralizacja danych. Zauważ, że jeśli na ten moment otworzylibyśmy Twoją stronę np. na trzech komputerach, to każdy klient działałby tak naprawdę w izolacji, nie wiedząc co robią pozostałe. Zauważ bowiem, że każdy z nich działałby na własnej kopii danych. To powoduje masę ograniczeń.

Skąd np. mielibyśmy wiedzieć, czy wciąż można zarezerwować stolik nr 1 o godzinie 8? Może i na starcie był wolny, ale przecież w międzyczasie inna osoba mogła go zarezerwować... A nawet gdybyśmy to zignorowali, to jak moglibyśmy poinformować pracowników pizzerii o tej rezerwacji? Zmieniając zawartość pliku data.js, a dokładnie jego tablicę z zamówieniami (order)? Raczej nie. Przecież pizzeria nie wie, co dzieje się w pliku data.js na komputerze naszym czy też innych klientów. Rozumiesz już pewnie problem. Musimy mieć jakąś jedną centralę, z której korzystają wszyscy użytkownicy. Centralę z danymi.

I taką rolę będzie właśnie pełnił nasz serwer API. Będzie serwerem z danymi, który udostępnia możliwość ich modyfikacji przy użyciu endpointów. Dzięki temu każdy klient na starcie aplikacji będzie w stanie pobrać aktualne dane startowe, ale też później wedle woli je odświeżać, czy nawet informować o dodaniu czegoś nowego, np. nowego zamówienia. Dzięki temu inni klienci przy próbie rezerwacji tego samego terminu mogliby już być informowani przez serwer, że ten jest akurat zajęty.

Podsumowując, mamy wielu klientów, ale jedną centralę, która przechowuje wspólne dane, wszystko kontroluje i pozwala dojść do nich przy użyciu endpointów.

Tym samym dochodzimy też to trzeciej zalety – uniwersalność. Bardzo często jest tak, że jedna baza danych jest wykorzystywana przez kilka stron/aplikacji. Np. możemy wyobrazić sobie, że nasz serwer jest wykorzystywany nie tylko przez stronę z produktami, ale też zupełnie inną aplikację – panel administracyjny, z którego korzystają pracownicy pizzerii.

Zapewne znasz koncept "kiosków" w sieciach KFC czy McDonalds. W nich też jest uruchomiona jakaś aplikacja, która musi mieć dostęp do tych samych danych, co np. strona internetowa sieci. Zapewne więc aplikacja w "kiosku", aplikacja w panelach pracowników czy sama strona internetowa dla klientów, korzystają z jednego i tego samego serwera API.

Żyjemy w symulacji ;)

Musimy jeszcze wyjaśnić jedną rzecz. Mówimy o centralnym serwerze, o tym, że wiele komputerów będzie mogło się z nim łączyć. Czy nasz serwer też taki będzie? No cóż... Nie do końca.

My będziemy uruchamiać na razie nasz serwer lokalnie, tylko na naszym komputerze. Oznacza to, że będzie się mogło z nim łączyć wielu klientów, ale tylko z poziomu naszego komputera, czyli np. będziemy mogli otworzyć pięć razy stronę pizzerii i każda zakładka będzie nowym klientem korzystającym z jednego i tego samego serwera. Nie będzie jednak możliwości połączenia z zewnątrz, z całkiem innych komputerów, a w "normalnych" produkcyjnych serwerach jest to możliwe. Czy to jednak coś nam zmieni?

Tak naprawdę nie. Połączenie z serwerem lokalnym czy zdalnym (obecnym na innym komputerze) jest takie samo. Tak samo będziemy wysyłać do niego request i oczekiwać na odpowiedź. Na dobrą sprawę nasz serwer lokalny będziemy mogli bardzo łatwo zamienić na serwer zdalny i korzystać z niego tak samo, jak wcześniej. To naprawdę nie jest żaden problem. To, czego będziemy się więc teraz uczyć, możesz więc spokojnie traktować jako ogólną wiedzę na temat połączenia z serwerem, nie tylko lokalnym.

Instalacja json-server

Zaczniemy od zainstalowania paczki json-server za pomocą komendy:

npm install --save json-server

Plik z danymi

Następnie musimy przygotować dane, z których będzie korzystać nasz serwer. Niestety nie możemy wykorzystać wprost z pliku data.js, gdyż json-server oczekuje na format danych JSON, a tam mamy obiekty JS-owe.

W katalogu src stwórz więc katalog db, a w nim plik app.json. To właśnie on będzie on zawierał dane, które nasz serwer zapisze jako startową zawartość bazy danych. Oczywiście ten folder mógłby nazywać się również inaczej, ale nazwa db dobrze oddaje jego zawartość.

Nasze założenie jest takie, że obiekt dataSource zawarty w pliku src/js/data.js stanie się zbędny. Zamiast bowiem importować dane z tego pliku, będziemy pobierać je z serwera. Za chwile się więc go pozbędziemy.

Chcielibyśmy, aby app.json jako dane startowe, przyjął po prostu zawartość pliku data.js, ale... wiesz już, że format JSON różni się trochę od zwykłej struktury obiektu, a w pliku data.js jest właśnie zwykły obiekt JS-owy...

Musimy więc skonwertować nasze dane z pliku data.js do formatu JSON, dopiero wtedy będziemy mogli wkleić je do app.json. Możesz to zrobić chociażby przy użyciu jednego z dostępnych w internecie konwerterów.

Gdy otrzymasz już dane w formacie JSON, skopiuj je i wklej do pliku app.json. Możesz również pozbyć się pliku data.js. Nie będzie nam już potrzebny.

Task runner

Czas przygotować task, który będzie uruchamiał przy pomocy paczki json-server nasz serwer.

Wejdź do pliku package.json, w sekcji scripts, znajdź te linie:

"watch": "npm-run-all build build-dev -p watch:*",
"watch:browsersync": "browser-sync start --server dist --files \"dist/**/*\"",

Zzamień je na następujące:

"server": "json-server --port 3131 --no-cors --delay 250 --watch dist/db/app.json",
"watch": "npm-run-all build build-dev -p watch:* server",
"watch:browsersync": "browser-sync start --server dist --files \"dist/**/*\" --ignore \"dist/db/**/*\"",

Dodaliśmy w ten sposób task server, który będzie uruchamiał nasz json-server na porcie 3131. Połączenie z lokalnym serwerem jest znacznie szybsze, niż z serwerem funkcjonującym w internecie (zdalnym), więc dodaliśmy opóźnienie 250ms (1/4 sekundy), tak aby efekt był bardziej realistyczny.

Dodatkowo w tasku watch dodaliśmy wywołanie taska server, a w tasku watch:browsersync poprosiliśmy nasz serwer z podglądem strony, aby nie odświeżał podglądu, kiedy zmienia się plik z danymi.

Teraz gdy tylko uruchomimy task watch, zawsze będzie uruchamiał się również nasz serwer API.

Zauważ, że tym samym będziemy posiadać już dwa serwery. Jeden (localhost:3000) serwuje naszą stronę, drugi (localhost:3131) udostępnia endpointy do komunikacji z bazą danych. Oczywiście można by połączyć je w jeden... ale to "zabawa" na później.

Test API

Teraz zrestartuj swój task watch, czyli wyłącz go i znów uruchom npm run watch. Ponownie otworzy się strona pizzerii, którą tworzymy. Dba o to serwer localhost:3000.

Wiemy też, że watch na pewno uruchomiło również drugi serwer. Otwórz więc nową zakładkę i przejdź pod adres http://localhost:3131/. To adres naszego serwera API.

Mówiliśmy, że serwer API udostępnia przede wszystkim endpointy do obsługi danych, ale akurat nasz oferuje nam jeszcze jeden endpoint /, który można potraktować jako dokumentację.

Właśnie pod tym adresem zobaczysz stronę wygenerowaną przez serwer json-server, która powie nam, jakie endpointy dokładnie mamy do dyspozycji! Pamiętasz jak mówiliśmy, że autorzy serwerów zawsze udostępniają nam dokumentację? Tutaj jest ona dostępna właśnie pod adresem / serwera, jako mała strona HTML.

Wejdźmy teraz pod endpoint, z którego będziemy korzystać za chwilę również w aplikacji http://localhost:3131/product. Pamiętasz jak mówiliśmy, że endpoint GET /product będzie zwracał nam wszystkie produkty? Wygląda na to, że tak właśnie się dzieje! Serwer naprawdę po wejściu w taki endpoint zwrócił nam dane – nasze produkty.

Możesz zastanawiać się jakiej metody użyła przeglądarka, wchodząc na ten endpoint (adres). Dlaczego uznała, że użyliśmy metody GET? Z prostego powodu. Gdy wchodzimy na jakiś adres w przeglądarce, to zawsze domyślnie wykorzystywana jest właśnie ta metoda.

Niedługo będziemy wykonywać requesty za pomocą AJAX-u, w naszej aplikacji. Wtedy sami będziemy decydować o użytej metodzie.

Nasza strona będzie korzystać na razie tylko z tego jednego endpointu (adresu), ale możesz też sprawdzić, jak będzie wyglądało pobranie pojedynczego produktu, dodając do adresu slash / oraz id jednego z produktów – np. http://localhost:3131/product/cake.

Ważna uwaga – plik z danymi stworzyliśmy w katalogu src/db, ale serwer korzysta z pliku dist/db/app.json. Jeśli zechcesz ręcznie zmienić zawartość pliku z danymi, zmieniaj wyłącznie plik src/db/app.json. Zostanie on automatycznie skopiowany do dist/db, a API natychmiast zacznie korzystać z nowej wersji pliku. Wybraliśmy takie rozwiązanie, ponieważ już niedługo będziemy zapisywać zamówienia do API. Jako że jest to nasze testowe API, nie chcemy przechowywać w repozytorium testowych zamówień. Dlatego złożone zamówienia będą kasowane przy każdym uruchomieniu tasków watch i build.

Serwer API już działa, widzimy nawet, że udostępnia kilka endpintów – teraz czas przystosować nasz projekt do korzystania z niego.

Pobieranie danych z API

Otwórz plik src/js/script.js i znajdź w swoim kodzie obiekt settings. Dodaj do niego:

db: {
  url: '//localhost:3131',
  product: 'product',
  order: 'order',
},

Jest to konfiguracja parametrów, które będą nam potrzebne do łączenia się z API. Następnie znajdź metodę app.initData, która znajduje się pod koniec pliku.

Zaczniemy od zastąpienia dataSource pustym obiektem. Nie chcemy już bowiem korzystać z tego obiektu. Zatem dotychczasowa linia kodu będzie teraz wyglądać tak:

thisApp.data = {};

Następnie potrzebujemy zapisać w stałej url adres endpointu, który nas interesuje, czyli:

const url = settings.db.url + '/' + settings.db.product;

Jak możesz się domyślić, po połączeniu stringów wyjdzie nam adres http://localhost:3131/product, pod którym jak już wiesz, serwer udostępnia listę produktów.

No dobrze, czas w końcu rozkazać JS-owi, aby połączył się z tym endpointem. Ciągle o tym mówimy, czas naprawdę to zrobić.

Użyjemy do tego celu wbudowanej funkcji fetch. Możesz zapoznać się z jej składnią w naszym poradniku. Pomoże Ci to zrozumieć, czym są metody .then i dlaczego "doklejamy" je do fetch(url). Najprościej możemy jednak rozumieć funkcję schowaną w pierwszym .then jako funkcję, która uruchomi się wtedy, gdy request się zakończy, a serwer zwróci odpowiedź.

Nie uzupełniaj jeszcze tego kodu w miejscach, w których znajdują się komentarze – skup się na razie na jego zrozumieniu.

image

Przeanalizujmy, co on właściwie robi. Najpierw za pomocą funkcji fetch wysyłamy zapytanie (request) pod podany adres endpointu. Następnie otrzyma odpowiedź, która jak już wiesz, jest w formacie JSON. Widzieliśmy to wcześniej w przeglądarce. Dalej konwertujemy więc tę odpowiedź na obiekt JS-owy. Wreszcie, po otrzymaniu skonwertowanej odpowiedzi parsedResponse, wyświetlamy ją w konsoli.

To dokładnie to, o czym wcześniej mówiliśmy. Łączymy się z serwerem (request), czekamy na odpowiedź, i jak ją otrzymamy, to coś z nią robimy. W naszej sytuacji konwertujemy ją z JSON-a do obiektu JS i wyświetlamy.

Swoją drogą zauważ, że nie podaliśmy nigdzie z jakiej metody chcemy skorzystać. Nie musieliśmy. fetch domyślnie korzysta z metody GET, a właśnie z takiej musieliśmy skorzystać, jeśli chcieliśmy pobrać dane. Zauważ, że kiedy testowaliśmy, czy ten endpoint działa, wchodząc pod adres url w przeglądarce, też łączyliśmy się przy użyciu metody GET i otrzymaliśmy dobre dane. Teraz zrobiliśmy dokładnie to samo. Połączyliśmy się z tym samym serwerem, tym samym endpointem, przy użyciu tej samej metody. Tyle, że tym razem zrobiliśmy to asynchronicznie, z poziomu już załadowanej strony i to bez potrzeby przeładowywania czegokolwiek. Imponujące, prawda?

Składnia fetch z then może na razie wydawać się trochę specyficzna, ale możesz zrozumieć całość jako prosty rozkaz:

  1. Połącz się z adresem url przy użyciu metody fetch.
  2. Jeśli połączenie się zakończy, to wtedy (pierwsze .then) skonwertuj dane do obiektu JS-owego.
  3. Kiedy i ta operacja się zakończy, to wtedy (drugie .then) pokaż w konsoli te skonwertowane dane.

Zauważ, że obie funkcje w then uruchomią się dopiero w momencie zakończenia jakieś operacji. Pierwsze then czeka na zakończenie reqestu, a drugie konwersji danych. Wcześniej JS nawet ich nie "dotknie".

Uwaga – po tych zmianach przez chwilę strona nie będzie wyświetlać produktów. Nie pokaże się też żadna wartość w konsoli przy komunikacie "thisApp.data". Za chwilę zajmiemy się obiema kwestiami.

Sprawdź teraz, co pokazuje konsola na stronie pizzerii – przy komentarzu "parsedResponse" powinna pokazywać tablicę z produktami. Jeśli masz dobrą pamięć, być może pamiętasz, że w data.js produkty nie miały właściwości id, a zamiast tablicy product mieliśmy obiekt products. W związku z tym każdy produkt miał swój klucz – który teraz mamy zapisany jako właściwość id. Musieliśmy jednak dostosować strukturę danych do możliwości json-server, dlatego będziemy musieli wprowadzić drobne zmiany w kodzie.

Jedyne miejsce, w którym wykorzystywaliśmy klucz produktu, znajduje się w metodzie app.initMenu.

new Product(productData, thisApp.data.products[productData]);

Zamiast klucza, wykorzystamy teraz właściwość id:

new Product(thisApp.data.products[productData].id, thisApp.data.products[productData]);

Teraz jesteśmy gotowi, aby dokończyć implementację AJAX-a. W metodzie app.init znajdź wywołanie metody initMenu i skasuj je. Przejdź do funkcji, która otrzymuje parsedResponse i uzupełnij ją zgodnie z wpisanymi komentarzami.

Po tej operacji produkty powinny z powrotem wyświetlać się na stronie, a także powinny działać zmiany opcji i liczby sztuk. Tak jak wcześniej, powinno działać też dodawanie ich do koszyka, zmiana liczby sztuk, oraz usuwanie z koszyka.

Zwróć uwagę na kolejność wyświetlania komunikatów w konsoli. Komunikat "thisApp.data" wyświetla się przed komunikatem "parsedResponse", mimo że w kodzie jest niżej. Co więcej, nie wyświetla zawartości thisApp.data.

Tak właśnie działa asynchroniczność, o której wspomnieliśmy. Kod w funkcji otrzymującej argument parsedResponse wykonuje się dopiero wtedy, kiedy otrzyma odpowiedź z serwera. Z tego też powodu nie wyświetla się zawartość thisApp.data – w momencie wykonania console.log ten obiekt jest jeszcze pusty. Właśnie dlatego musieliśmy do tej funkcji przenieść uruchomienie metody initMenu – inaczej uruchamiałaby się, zanim nasz skrypt otrzymałby z serwera listę produktów.

Podsumowanie

I jak Ci się podoba ten AJAX? Na razie niewiele nam zmienił, ponieważ wczytujemy dane tuż po odświeżeniu strony. Było to jednak ważne ćwiczenie, które pozwoli nam za chwilę na wysyłanie zamówień do API!

Teraz kiedy nasza aplikacja już korzysta z API, możesz w pliku src/index.html usunąć tag <script>, który odwołuje się do pliku js/data.js. Ten plik nie będzie już nam potrzebny. Zresztą, już go nawet usunęliśmy.

Zadanie: zrozumienie AJAX-a

W tym module Twoim zadaniem jest zrozumienie AJAX-a. Aby to osiągnąć, w pliku package.json znajdź task server i zmień w nim fragment --delay 250 na --delay 3000. Teraz odpowiedzi z API będą przychodziły dopiero po 3 sekundach.

Wyłącz i ponownie włącz task watch, a następnie otwórz konsolę na stronie pizzerii i odśwież stronę. Zwróć uwagę, że menu nie pokazuje się przez ok. 3 sekundy od wyświetlenia strony. Również komunikat w konsoli zaczynający się od parsedResponse wyświetla się dopiero po 3 sekundach.

Przełącz teraz narzędzia developerskie z Console na Network. Po kolejnym odświeżeniu strony zobaczysz tutaj sporo plików – a konkretniej, wszystkie pliki pobierane przez stronę. Znajdziesz tu zarówno sam plik index.html (pod pierwszą pozycją "localhost"), jak również wszystkie pliki .css i .js, a także czcionki i obrazki.

image

Zwróć uwagę na czas odpowiedzi z serwera zaznaczony na grafie po prawej stronie. Zobaczysz na nim, że większość zapytań trwała tylko moment, poza zapytaniem do API, które specjalnie opóźniliśmy.

Teraz skupimy się wyłącznie na komunikacji z naszym API – kliknij ikonę filtra (wygląda jak lejek, znajdziesz ją obok ikony lupy na górnym pasku) i w polu Filter wpisz product.

image

Następnie kliknij na nazwę znalezionego połączenia – otworzy się widok, w którym zobaczysz wszystkie nagłówki (headers). W sekcji General znajdują się najważniejsze nagłówki naszego zapytania – takie jak np. adres, z którym się połączyliśmy. Bardzo ważną pozycją jest Status Code, w której znajdziesz kod odpowiedzi HTTP, informujący o tym, czy połączenie się powiodło (kod 200), czy np. nie znaleziono takiego adresu (kod 404) lub nie możemy zobaczyć tej treści bez zalogowania (kod 403).

image

Poniżej w sekcji Response Headers znajdziesz nagłówki odpowiedzi z serwera. Pokażą Ci np. że zawartość odpowiedzi jest w formacie JSON, z kodowaniem znaków w UTF-8 (Content-Type: application/json; charset=utf-8).

Czym są nagłówki?

Każde zapytanie do serwera i każda jego odpowiedź zawiera nagłówek (header) oraz opcjonalnie treść (body). W nagłówku znajdziesz parametry tego zapytania. Większość z nich jest automatycznie nadawana przez przeglądarkę lub serwer, ale niektóre z nich będą również nadawane przez nas – przekonasz się o tym, gdy będziemy wysyłać zamówienie do API.

Kolejnym miejscem, które zawiera wartościowe informacje, jest zakładka Preview, w której znajdziesz treść odpowiedzi z serwera. W naszym przypadku będzie to lista produktów, które przesłało nasze API w odpowiedzi na zapytanie.

Te informacje mogą nie być bardzo interesujące w tym momencie, ale warto już teraz sprawdzić, w jaki sposób możemy badać połączenia z API. Dzięki temu łatwiej będzie nam za chwilę sprawdzić, jak działa wysyłanie zamówienia do API.

Pamiętaj, aby w package.json z powrotem ustawić --delay 250 i zrestartować task watch!

9.9. Wysyłanie zamówienia do API

Potrafisz już pobierać dane z API i wyświetlać je na stronie. Teraz zajmiemy się odwrotną operacją, czyli wysyłaniem danych do API. W naszym przypadku będą to dane zamówienia: wykorzystamy do tego element <form>, znajdujący się w koszyku.

Idea będzie następująca. Dodamy nasłuchiwacz na nasz formularz. Kiedy wykryjemy, że ktoś chce go wysyłać (kliknięto na przycisk "Order"), skompletujemy informacje na temat zamówienia i wyślemy je za pomocą fetch do serwera. Oczywiście pod odpowiedni endpoint i przy użyciu odpowiedniej metody. Serwer powinien je odebrać i zapisać w bazie danych. Tym samym pracownicy pizzerii będą w stanie dowiedzieć się o nowym zamówieniu. Wszystko jasne?

Wychwycenie submitu formularza

W koszyku umieściliśmy formularz, który domyślnie przeładowuje stronę po wciśnięciu guzika "ORDER". Zaczynamy więc od zablokowania tej domyślnej akcji.

Ćwiczenie

Działamy oczywiście w klasie Cart.

Zacznij od przygotowania referencji do elementu formularza. thisCart.dom.form powinien kierować do elementu ukrytego pod selektorem select.cart.form (to nasz formularz).

Następnie w metodzie Cart.initActions dodaj nasłuchiwacz dla tego formularza. Nasłuchuj na event 'submit', a w funkcji callback dodaj instrukcję, która zablokuje domyślne zachowanie formularza. Po to, aby jego wysyłka nie przeładowywała strony. Od razu sprawdź, czy blokada domyślnej akcji działa – kliknięcie guzika "ORDER" nie powinno teraz niczego robić.

Oprócz tego, w tej samej funkcji callback dodaj wywołanie metody thisCart.sendOrder. Zajmiemy się nią szczegółowo za chwilę. Jej zadaniem będzie właśnie kompletowanie informacji o zamówieniu i późniejsza wysyłka ich do serwera. Wywołaj ją bez żadnych argumentów.

Pamiętaj, że wszystkie referencje do elementów DOM przygotowujemy w metodzie getElements. Dla czytelności.

Domyślne zachowanie zdarzenia możemy wyłączyć za pomocą metody preventDefault.

Następnie możemy zająć się tą metodą. Dodaj więc ją teraz sendOrder i standardowo zadbaj o przygotowanie skrótu do this (const thisCart = this).

Co dalej?

Zaczniemy od przygotowania adresu endpointu, z którym chcemy się połączyć.

const url = settings.db.url + '/' + settings.db.order;

Oczywiście otrzymamy z takiego połączenia link http://localhost:3131/order.

Następnie czas przygotować dane, które chcemy wysłać do serwera. Utworzenie obiektu z odpowiednimi danymi będzie właśnie Twoim zadaniem.

Ćwiczenie

Serwer w przypadku zamówień będzie oczekiwał na następujący obiekt:

{
  address: adres klienta wpisany w koszyku,
  phone: numer telefonu wpisany w koszyku,
  totalPrice: całkowita cena za zamówienie,
  subTotalPrice: cena całkowita - koszt dostawy,
  totalNumber: całkowita liczba sztuk,
  deliveryFee: koszt dostawy,
  products: tablica obecnych w koszyku produktów
}

Twoim zadaniem jest stworzenie obiektu o nazwie payload właśnie o takiej strukturze. Postaraj się poprawnie przygotować wartość wszystkich pól z wyjątkiem ostatniego. Nim zajmiemy się dopiero za chwilę. Na razie payload.products będzie po prostu pustą tablicą.

Aby sprawdzać, jak Ci idzie, warto dodać pod tym obiektem console.log pokazujący jego zawartość. Dzięki temu łatwo będziesz w stanie ustalić, czy obiekt zawiera wszystko, co trzeba.

  1. W tej chwili prawdopodobnie nie masz jeszcze przygotowanych referencji do inputów phone czy address. Warto dodać je więc do getElements przed rozpoczęciem pracy.
  2. Aby dojść do wartości elementu input, skorzystaj z jego właściwości value.
  3. Bardzo łatwo możesz dojść do wartości totalPrice. Dlaczego? Gdy pisaliśmy metodę update, to zadbaliśmy o to, aby właśnie ta informacja była zapisana jako właściwość (thisCart.totalPrice). Zrobiliśmy to z myślą o przyszłości. Tak, żeby właśnie w takiej sytuacji jak ta, móc łatwo dojść do tej informacji. totalNumber czy subTotalPrice były już jednak w tamtej funkcji zapisywane tylko jako stałe, a co za tym idzie, dostępne tylko w niej... Może więc warto zmodyfikować tamtą metodę? Tak, aby totalNumber i subTotalPrice były również właściwościami? Wtedy będzie można dojść do ich wartości również poza metodę update. Właśnie np. w sendOrder!

Teraz zajmiemy się już payload.products.

W Twojej klasie Cart jest już właściwość, która może wydawać się wręcz idealna dla payload.products. Mowa o thisCart.products, czyli tablicy z instancjami cartProduct. Jednak czy aby na pewno?

Zauważ, że instancja klasy cartProduct posiada informacje, które serwerowi się przydadzą (np. id, name, price, amount...), ale też masę niepotrzebnych, np. cały obiekt thisCartProduct.dom z referencjami do elementów HTML produktu jest kompletnie zbędny! Co możemy więc w takiej sytuacji zrobić?

Przypomnij sobie, jak rozwiązaliśmy ten problem w momencie przekazywanie produktu z klasy Product do koszyka. Mieliśmy tam analogiczną sytuację. Nie chcieliśmy przekazywać całej instancji, więc przygotowaliśmy metodę prepareCartProduct, która przygotowywała nam nowy obiekt. Bardzo mały, będący podsumowaniem całej instancji i posiadający tylko te właściwości, które koszykowi są potrzebne.

Tak samo postąpimy również teraz.

Ćwiczenie

Przejdź do klasy CartProduct i przygotuj w niej nową metodę getData. Zadbaj o to, aby zwracała ona nowy obiekt, który posiada tylko te właściwości z całej instancji thisCartProduct, które naprawdę będą potrzebne w momencie zapisywania zamówienia, a więc id, amount, price, priceSingle, name i params.

Kiedy już to zrobisz, możemy bardzo łatwo zapełnić naszą tablicę payload.products nie całymi instancjami produktów w koszyku, a tylko tymi mini obiektami z ich podsumowaniem.

Wróć więc do metody Cart.sendOrder i pod obiektem payload dodaj następujący kod:

for(let prod of thisCart.products) {
  payload.products.push(prod.getData());
}

Popatrz tylko. Nie dodajemy do payload.products całych instancji. Dodajemy tylko obiekty podsumowania. Te małe, z zaledwie kilkoma właściwościami. Bo i po co serwerowi więcej? Po co obsłudze pizzerii więcej? Nazwa produktu, liczba sztuk, wybrane opcje i cena spokojnie wystarczą im do realizacji zamówienia.

Mamy już przygotowany link, z którym chcemy się połączyć. Przygotowaliśmy również dane do wysyłki. Czas na wywołanie fetch.

fetch(url);

Czy to jednak wystarczy? Nie.

Po pierwsze, fetch domyślnie łączy się z serwerem za pomocą metody GET, a ta służy do pobierania danych. Tym razem chcemy wysyłać dane, więc musimy skorzystać z metody POST. Na szczęście takie dodatkowe opcje możemy w fetch bardzo łatwo ustawić za pomocą drugiego argumentu:

fetch(url, { method: 'POST' });

To wciąż nie wszystko... Skąd serwer ma wiedzieć, co dokładnie trzeba dodać do bazy danych? Musimy mu to powiedzieć. Trzeba więc wraz z requestem wysłać również nasz payload. Możemy to zrobić przy użyciu obiektu body.

fetch(url, { method: 'POST', body: payload });

To jednak wciąż za mało. Serwer komunikuje się z nami przy użyciu formatu JSON, a payload to zwykły obiekt JS-owy. Musimy więc skonwertować go jeszcze na format JSON. Warto również przy użyciu nagłówków (headers) poinformować serwer o tym, że ma spodziewać się właśnie JSON-a.

fetch(url, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(payload)
});

Wygląda to trochę brzydko. Warto wyciągnąć sobie opcje do osobnej stałej, dla czytelności.

const options = {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(payload),
};

fetch(url, options);

Podsumujmy teraz całość.

Zaczynamy od stałej url, w której, podobnie jak wcześniej, umieszczamy adres endpointu. Tym razem będziemy się kontaktować z endopointem zamówienia (order).

Następnie deklarujemy stałą payload, czyli ładunek. Tak bardzo często określa się dane, które będą wysłane do serwera.

Kolejna stała – options – zawiera opcje, które skonfigurują zapytanie. Po pierwsze, musimy zmienić domyślną metodę GET na metodę POST, która służy do wysyłania nowych danych do API. Następnie musimy ustawić nagłówek, aby nasz serwer wiedział, że wysyłamy dane w postaci JSON-a. Ostatni z nagłówków to body, czyli treść, którą wysyłamy. Używamy tutaj metody JSON.stringify, aby przekonwertować obiekt payload na ciąg znaków w formacie JSON.

Możesz zauważyć, że tym razem nie przypięliśmy do wywołania fetch żadnej funkcji w .then. Dlaczego?

A czy musimy? Zauważ, że tak naprawdę teraz, nie odbieramy żadnych danych. Wręcz przeciwnie, to my je wysyłamy. Nie za bardzo więc obchodzi nas, co serwer zwróci jako odpowiedź.

Jeśli jednak chcesz, możesz dodać funkcję w .then i ją odczytać. json-server w przypadku sukcesu powinien zwrócić po prostu obiekt, który został dodany do bazy, czyli w naszej sytuacji nasz payload.

image

Czy to już wszystko i zamówienie wyśle się do serwera? Sprawdźmy! Dodaj jakiś produkt do koszyka i wciśnij guzik "ORDER". Pozornie nic się nie stało, ale sprawdź, jaki komunikat wyświetlił się w konsoli.

Jeśli wszystko poszło dobrze, zobaczysz swoje zamówienie z polami: address, totalPrice, id... Ale chwila, my nie wysyłaliśmy żadnego id, prawda? To pole zostało dodane przez API. Dzięki temu każde dodane zamówienie ma swój unikalny identyfikator. Nawet jeśli wiele osób jednocześnie wysłałoby zamówienie, nie musimy się obawiać, że dwa zamówienia będą miały to samo id.

Pamiętaj, że listę wszystkich zamówień możesz zobaczyć przez endpoint GET http://localhost:3131/order (możesz otworzyć go w przeglądarce). Możesz odświeżać stronę do woli, a złożone zamówienia nie znikną! Dzięki temu moglibyśmy napisać prosty panel administracyjny, który umożliwiałby wyświetlanie (a nawet edycję!) wszystkich zamówień.

Zadanie: agregacja danych z koszyka

Zadanie jest już gotowe, ale ostatnio wykonaliśmy sporo pracy, której Mentor nie miał jeszcze okazji sprawdzić. Zaktualizuj swoje repo i pochwal się swoim wysiłkiem :)

9.10. Podsumowanie

W tym module udało nam się stworzyć całą funkcjonalność koszyka – od dodawania do niego produktów z menu, przez podliczanie ceny zamówienia, aż po wysyłanie zamówienia do API!

Jak teraz widzisz, AJAX nie jest niczym strasznym. Dużo więcej czasu spędziliśmy nad agregowaniem danych do wysłania, niż nad samą implementacją AJAX-a. W następnych modułach będziemy dalej pracować z OOP, AJAX-em i API – dzięki temu zdobędziesz wprawę w posługiwaniu się tymi technikami, bez których dzisiejsze strony już nie mogą się obejść.

Zanim zamkniesz ten moduł, zatrzymaj się na chwilę i pobaw się stroną pizzerii. W ciągu dwóch modułów stworzyliśmy całą jej funkcjonalność, podczas gdy dopiero kilka tygodni temu zaczęliśmy naukę JS-a. Niesamowite!

Oczywiście, możesz powiedzieć, że w tym projekcie sporo Ci pomogliśmy, przygotowując pliki projektu – ale przecież ostylowanie projektu nie sprawiłoby Ci już wielkiego problemu, prawda? Funkcje w pliku functions.js były też bardzo przydatne, ale przy odrobinie samozaparcia udałoby Ci się znaleźć je w internecie. Wreszcie cały algorytm skryptu, który dla Ciebie opisaliśmy – staraliśmy się pokazać Ci, jak rozwijamy go krok po kroku. Najpierw pisaliśmy ogólny plan nowych funkcjonalności, aby potem skupić się na jednym jego fragmencie, i testować go na każdym kroku za pomocą console.log.

Gdybyśmy parę tygodni temu pokazali Ci tę stronę, pewnie usłyszelibyśmy, że nie wiesz nawet, od czego zacząć. Mamy nadzieję, że teraz już wiesz, iż nawet bardzo skomplikowane skrypty można rozbić na małe, proste w realizacji kroki.

Na tym kończymy wspólną pracę nad menu i koszykiem naszej pizzerii. Jednak jeśli zechcesz i będziesz mieć czas – teraz lub po ukończeniu kursu – możesz kontynuować pracę nad tym projektem.

Stworzenie dotychczasowych skryptów kosztowało nas sporo pracy. Musimy jednak do tego przywyknąć. Pisanie w czystym JS (tzw. VanillaJS) daje nam ogromną swobodę, ale też wymaga mnóstwa pracy. Nie bez powodu na rynku rozwiązania ułatwiające budowanie dużych aplikacji takie jak React czy Angular są aż tak popularne. Korzystanie z nich jest zwyczajnie łatwiejsze. Dlatego też nie przejmuj się, jeśli czujesz, że nasza pizzeria jest dla Ciebie ciężka. Już niedługo tego typu aplikacje będziemy pisali znacznie przyjemniej i szybciej przy użyciu Reacta. Potraktuj projekt Pizzerii jako ciężki początek czegoś znacznie przyjemniejszego.

Dla chętnych

Jeśli zechcesz rozbudować funkcjonalność tego projektu, mamy kilka pomysłów, których wdrożenie na pewno podniosłoby jego jakość.

  1. Po dodaniu produktu z menu do koszyka, ten produkt powinien powracać do domyślnego stanu (takiego samego, jak tuż po wczytaniu strony). Podobnie, po wysłaniu zamówienia, koszyk powinien się opróżniać.
  2. Nie powinno być możliwości wysłania zamówienia, kiedy brakuje numeru telefonu, adresu, lub produktów w koszyku. W przypadku numeru telefonu i adresu można wprowadzić podstawową walidację, opartą np. o długość wpisanego tekstu. Błędne pola powinny otrzymać klasę error, która np. zmieni kolor ich obramowania na czerwony. Walidacja musi działać również po zmianie wartości (event change), aby po wpisaniu poprawnej wartości nie było już czerwonej ramki.
  3. Koszyk mógłby mieć jakiś wizualny efekt przy zmianie jego zawartości. Np. w momencie zmiany jego zawartości, ceny i ilość sztuk w koszyku mogłyby na moment dostawać klasę, dla której byłoby ustawione opacity: 0.5;. W połączeniu ze stylem transition, może to dać ciekawy efekt – szczególnie jeśli dla tej klasy ustawimy transition: none;, a dla elementów bez tej klasy, np. transition: opacity 0.5s;.
  4. Nasze zamówienie, zapisane w bazie, dla każdego produktu zawiera jego parametry i ich opcje. Nie są nam jednak potrzebne ich nazwy (label), ponieważ zakładamy że API potrzebuje znać tylko id parametrów i opcji. Dobrym pomysłem byłaby zmiana w CartProduct.getData, aby zamiast zwracać thisCartProduct.params, stworzyć nowy obiekt, który zawierałby wyłącznie id parametrów i opcji.
  5. Aby korzystanie ze strony było wygodniejsze, możesz sprawić, aby nagłówek był sticky, czyli był zawsze widoczny na górze okna przeglądarki, niezależnie od przewijania strony.
  6. Jeśli dobrze radzisz sobie z JS-em, możesz też spróbować stworzyć własną klasę API, która będzie upraszczać korzystanie z komunikacji z serwerem. Warto wtedy rozszerzyć jej funkcjonalność o wychwytywanie błędów, wyszukując w Google np. "js fetch catch errors" czy "js fetch catch 404".

Jak widzisz, jest sporo rzeczy do zrobienia, ale zapewne nie znajdziesz w tej chwili czasu na ich wdrożenie. Tym bardziej istotne jest dla nas podejście obiektowe, czyli OOP. Dzięki temu, wracając do tego projektu za kilka miesięcy, będzie Ci znacznie łatwiej odnaleźć się w kodzie JS.

9.11. Quiz powtórkowy

Na koniec tego modułu przygotowaliśmy dla Ciebie quiz powtórkowy. Pomoże Ci on powtórzyć wiedzę z poprzednich modułów.

Odpowiedzi tego quizu nie są nigdzie zapisywane, więc są tylko do Twojej wiadomości. Ten quiz ma Ci posłużyć jako pomoc w nauce – dlatego pod każdym pytaniem znajdziesz guzik, który sprawdzi poprawność Twoich odpowiedzi oraz poda Ci wyjaśnienie zagadnienia poruszanego w tym pytaniu.

1. W poniższym kodzie wywołano kilka funkcji. Przy każdym wywołaniu dopisano numerek. Zastanów się, jaka będzie wartość this w wywoływanej funkcji w każdym z tych przypadków?

class Person() {
  constructor(arg) {
    this.elem = arg;
  }

  show() {
  }
}

const foo = function() {
  console.log(this)
}

const bar = {
  func: foo
}

const buttonForJohn = document.getElementById('button-john');
buttonForJohn.addEventListener('click', foo); // 1
const johnObject = new Person('John', buttonForJohn);
johnObject.show(); // 2

foo(); // 3
bar.func(); // 4

Wyjaśnienie

Kontekst funkcji, czyli to co znajduje się w obiekcie this, może być inny dla każdej funkcji. Z tego względu należy każde wywołanie rozpatrywać osobno. Nawet jeśli wywołujemy tę samą funkcję kilka razy, to zależnie od sytuacji może ona mieć inną wartość.

Jeśli nie masz pewności, czym powinno być this w danej sytuacji, przypomnij sobie kilka zasad, które sterują ustalaniem this przez JS-a. To znacznie ułatwi Ci sprawę.

2. Funkcje użyte w poniższym kodzie nie uruchomią się jednocześnie. Zaznacz poprawną kolejność ich wykonania, zakładając że odpowiedź na zapytanie do serwera otrzymamy po 1 sekundzie.

setTimeout(function(){
  displayAdvertisement();
}, 5000);

fetch("http://api.icndb.com/jokes/random")
  .then(function(resp) {
    return resp.json();
  })
  .then(function(result) {
    displayOnPage(result);
  })
  .catch(function(error) {
    console.warn(error);
  });

startSlider();

setTimeout(function(){
  playVideo():
}, 3000);

Wyjaśnienie

Zarówno setTimeout jak i callback fetcha wykonają się asynchronicznie. Pierwszy setTimeout wykona się po 5000ms, czyli 5 sekundach. Drugi, na końcu kodu, wykona się po 3 sekundach. Założyliśmy, że serwer przyśle odpowiedź na zapytanie po 1 sekundzie, czyli szybciej od wykonania któregokolwiek setTimeout.

Z funkcji wymienionych w odpowiedziach, jedynie startSlider będzie wykonany synchronicznie. Oznacza to, że wykona się natychmiast. W związku z tym poprawna kolejność to:

  • startSlider – funkcja wykonywana synchronicznie,
  • displayOnPage – callback fetcha, wykonany po odpowiedzi z serwera (założyliśmy 1 sekundę),
  • playVideo – funkcja wywołana przez setTimeout po 3 sekundach,
  • displayAdvertisement – funkcja wywołana przez setTimeout po 5 sekundach.

Istnieje jednak możliwość innej kolejności – gdyby wykonanie funkcji startSlider trwało bardzo długo, to ostatni setTimeout nie zostałby wykonany dopóki startSlider nie zakończy działania. Taka sytuacja mogłaby mieć miejsce np. gdyby funkcja startSlider wyświetliła alert – wtedy działanie JS będzie zatrzymane do czasu jego zamknięcia.

Załóżmy, że użytkownik pozostawił alert otwarty przez ponad 5 sekund – wtedy kolejność wykonania funkcji wyglądała tak:

  • startSlider – funkcja wykonywana synchronicznie,
  • displayOnPage – callback fetcha, wykonany po odpowiedzi z serwera (założyliśmy 1 sekundę), ale ponieważ w tym momencie był otwarty alert, to wykona się tuż po jego zamknięciu,
  • displayAdvertisement – funkcja wywołana przez setTimeout po 5 sekundach, ale ponieważ w tym momencie był otwarty alert, to wykona się tuż po jego zamknięciu,
  • playVideo – funkcja wywołana przez setTimeout po 3 sekundach od zakończenia działania funkcji startSlider.

Ten przykład pokazuje, że czasami nasz kod może zachowywać się inaczej niż byśmy się tego spodziewali. W szczególności w przypadku funkcji alert, prompt i confirm należy zachować szczególną uwagę (a najlepiej ich unikać, w miarę możliwości).

3. Wybierz poprawne przykłady formatu JSON:

Wyjaśnienie

To zadanie może przyprawiać o ból głowy, ale wystarczy pamiętać o paru zasadach, aby sobie z nim poradzić!

Po pierwsze, JSON może być obiektem lub tablicą. Zarówno klucze w obiektach, jak i wartości tekstowe muszą być zamknięte w podwójnych cudzysłowach " ".

Już na tym etapie możemy wykluczyć wszystkie przypadki, w których klucz w obiekcie nie jest zamknięty w cudzysłowach, lub są to pojedyncze cudzysłowy.

Idąc dalej, wartości w obiektach i tablicach mogą być liczbami (bez cudzysłowów), tekstami (z cudzysłowami), tablicami lub obiektami. Zwróć szczególną uwagę na przykład z "46" – liczbę możemy potraktować jako liczbę, albo jako tekst. Łatwo sobie wyobrazić sytuację, w której np. numer mieszkania zwykle będzie cyfrą, ale może też zawierać litery (np. 1b), więc bezpieczniej będzie zawsze traktować go jako tekst.

Jeśli wartością jest tablica, to nie zamykamy jej w cudzysłowach – stąd zapis "times": "["12:00", "14:00"]" jest niepoprawny i możemy albo użyć tablicy, czyli zapisać "times": ["12:00", "14:00"], albo potraktować to pole jako tekst, wtedy używając zapisu "times": "12:00, 14:00".

Możesz sprawdzić poprawność odpowiedzi oznaczonych jako poprawne, wklejając do konsoli (w narzędziach developerskich przeglądarki) poniższy fragment kodu:

console.log(JSON.parse(`{}`));
console.log(JSON.parse(`{"name": "John", "age": 46}`));
console.log(JSON.parse(`{"name": "John", "age": "46"}`));
console.log(JSON.parse(`["Paris", "London"]`));
console.log(JSON.parse(`[{"day": "Monday", "times": ["12:00", "14:00"]}, {"day": "Tuesday", "times": ["13:00", "17:00"]}]`));
console.log(JSON.parse(`[{"day": "Monday", "times": "12:00, 14:00"}, {"day": "Tuesday", "times": "13:00, 17:00"}]`));
;